@grafana/create-plugin 6.7.1-canary.2370.20798217827.0 → 6.8.0-canary.2356.20813734793.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,346 @@
1
+ import * as v from 'valibot';
2
+ import * as recast from 'recast';
3
+ import { coerce, gte } from 'semver';
4
+
5
+ import type { Context } from '../../../context.js';
6
+ import { additionsDebug } from '../../../utils.js';
7
+ import { updateBundlerConfig, type ModuleRulesModifier, type ResolveModifier } from '../../../utils.bundler-config.js';
8
+ import { updateExternalsArray, type ExternalsArrayModifier } from '../../../utils.externals.js';
9
+
10
+ const { builders } = recast.types;
11
+
12
+ const PLUGIN_JSON_PATH = 'src/plugin.json';
13
+ const MIN_GRAFANA_VERSION = '10.2.0';
14
+
15
+ export const schema = v.object({});
16
+ type BundleGrafanaUIOptions = v.InferOutput<typeof schema>;
17
+
18
+ export default function bundleGrafanaUI(context: Context, _options: BundleGrafanaUIOptions): Context {
19
+ additionsDebug('Running bundle-grafana-ui addition...');
20
+
21
+ // Ensure minimum Grafana version requirement
22
+ ensureMinGrafanaVersion(context);
23
+
24
+ // Update externals array using the shared utility
25
+ updateExternalsArray(context, createBundleGrafanaUIModifier());
26
+
27
+ // Update bundler resolve configuration to handle ESM imports
28
+ updateBundlerConfig(context, createResolveModifier(), createModuleRulesModifier());
29
+
30
+ return context;
31
+ }
32
+
33
+ /**
34
+ * Checks if an AST node is a regex matching @grafana/ui
35
+ * The pattern in the AST is "^@grafana\/ui" (backslash-escaped forward slash)
36
+ */
37
+ function isGrafanaUiRegex(element: recast.types.namedTypes.ASTNode): boolean {
38
+ // Handle RegExpLiteral (TypeScript parser)
39
+ if (element.type === 'RegExpLiteral') {
40
+ const regexNode = element as recast.types.namedTypes.RegExpLiteral;
41
+ return regexNode.pattern === '^@grafana\\/ui' && regexNode.flags === 'i';
42
+ }
43
+ // Handle Literal with regex property (other parsers)
44
+ if (element.type === 'Literal' && 'regex' in element && element.regex) {
45
+ const regex = element.regex as { pattern: string; flags: string };
46
+ return regex.pattern === '^@grafana\\/ui' && regex.flags === 'i';
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Checks if an AST node is a regex matching @grafana/data
53
+ * The pattern in the AST is "^@grafana\/data" (backslash-escaped forward slash)
54
+ */
55
+ function isGrafanaDataRegex(element: recast.types.namedTypes.ASTNode): boolean {
56
+ // Handle RegExpLiteral (TypeScript parser)
57
+ if (element.type === 'RegExpLiteral') {
58
+ const regexNode = element as recast.types.namedTypes.RegExpLiteral;
59
+ return regexNode.pattern === '^@grafana\\/data' && regexNode.flags === 'i';
60
+ }
61
+ // Handle Literal with regex property (other parsers)
62
+ if (element.type === 'Literal' && 'regex' in element && element.regex) {
63
+ const regex = element.regex as { pattern: string; flags: string };
64
+ return regex.pattern === '^@grafana\\/data' && regex.flags === 'i';
65
+ }
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * Removes /^@grafana\/ui/i regex from externals array and adds 'react-inlinesvg'
71
+ * @returns true if changes were made, false otherwise
72
+ */
73
+ function removeGrafanaUiAndAddReactInlineSvg(externalsArray: recast.types.namedTypes.ArrayExpression): boolean {
74
+ let hasChanges = false;
75
+ let hasGrafanaUiExternal = false;
76
+ let hasReactInlineSvg = false;
77
+
78
+ // Check current state
79
+ for (const element of externalsArray.elements) {
80
+ if (!element) {
81
+ continue;
82
+ }
83
+
84
+ // Check for /^@grafana\/ui/i regex
85
+ if (isGrafanaUiRegex(element)) {
86
+ hasGrafanaUiExternal = true;
87
+ }
88
+
89
+ // Check for 'react-inlinesvg' string
90
+ if (
91
+ (element.type === 'Literal' || element.type === 'StringLiteral') &&
92
+ 'value' in element &&
93
+ typeof element.value === 'string' &&
94
+ element.value === 'react-inlinesvg'
95
+ ) {
96
+ hasReactInlineSvg = true;
97
+ }
98
+ }
99
+
100
+ // Remove /^@grafana\/ui/i if present
101
+ if (hasGrafanaUiExternal) {
102
+ externalsArray.elements = externalsArray.elements.filter((element) => {
103
+ if (!element) {
104
+ return true;
105
+ }
106
+ return !isGrafanaUiRegex(element);
107
+ });
108
+ hasChanges = true;
109
+ additionsDebug('Removed /^@grafana\\/ui/i from externals array');
110
+ }
111
+
112
+ // Add 'react-inlinesvg' if not present
113
+ if (!hasReactInlineSvg) {
114
+ // Find the index of /^@grafana\/data/i to insert after it
115
+ let insertIndex = -1;
116
+ for (let i = 0; i < externalsArray.elements.length; i++) {
117
+ const element = externalsArray.elements[i];
118
+ if (element && isGrafanaDataRegex(element)) {
119
+ insertIndex = i + 1;
120
+ break;
121
+ }
122
+ }
123
+
124
+ if (insertIndex >= 0) {
125
+ externalsArray.elements.splice(insertIndex, 0, builders.literal('react-inlinesvg'));
126
+ } else {
127
+ // Fallback: append to end
128
+ externalsArray.elements.push(builders.literal('react-inlinesvg'));
129
+ }
130
+ hasChanges = true;
131
+ additionsDebug("Added 'react-inlinesvg' to externals array");
132
+ }
133
+
134
+ return hasChanges;
135
+ }
136
+
137
+ /**
138
+ * Creates a modifier function for updateExternalsArray that removes @grafana/ui
139
+ * and adds react-inlinesvg
140
+ */
141
+ function createBundleGrafanaUIModifier(): ExternalsArrayModifier {
142
+ return (externalsArray: recast.types.namedTypes.ArrayExpression) => {
143
+ return removeGrafanaUiAndAddReactInlineSvg(externalsArray);
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Creates a modifier function for updateBundlerConfig that adds '.mjs' to resolve.extensions
149
+ */
150
+ function createResolveModifier(): ResolveModifier {
151
+ return (resolveObject: recast.types.namedTypes.ObjectExpression): boolean => {
152
+ if (!resolveObject.properties) {
153
+ return false;
154
+ }
155
+
156
+ let hasChanges = false;
157
+ let hasMjsExtension = false;
158
+ let extensionsProperty: recast.types.namedTypes.Property | null = null;
159
+
160
+ // Check current state
161
+ for (const prop of resolveObject.properties) {
162
+ if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) {
163
+ continue;
164
+ }
165
+
166
+ const key = 'key' in prop ? prop.key : null;
167
+ const value = 'value' in prop ? prop.value : null;
168
+
169
+ if (key && key.type === 'Identifier') {
170
+ if (key.name === 'extensions' && value && value.type === 'ArrayExpression') {
171
+ extensionsProperty = prop as recast.types.namedTypes.Property;
172
+ // Check if .mjs is already in the extensions array
173
+ for (const element of value.elements) {
174
+ if (
175
+ element &&
176
+ (element.type === 'Literal' || element.type === 'StringLiteral') &&
177
+ 'value' in element &&
178
+ element.value === '.mjs'
179
+ ) {
180
+ hasMjsExtension = true;
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ // Add .mjs to extensions if missing
189
+ if (!hasMjsExtension && extensionsProperty && 'value' in extensionsProperty) {
190
+ const extensionsArray = extensionsProperty.value as recast.types.namedTypes.ArrayExpression;
191
+ extensionsArray.elements.push(builders.literal('.mjs'));
192
+ hasChanges = true;
193
+ additionsDebug("Added '.mjs' to resolve.extensions");
194
+ }
195
+
196
+ return hasChanges;
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Creates a modifier function for updateBundlerConfig that adds a module rule for .mjs files
202
+ * in node_modules with resolve.fullySpecified: false
203
+ */
204
+ function createModuleRulesModifier(): ModuleRulesModifier {
205
+ return (moduleObject: recast.types.namedTypes.ObjectExpression): boolean => {
206
+ if (!moduleObject.properties) {
207
+ return false;
208
+ }
209
+
210
+ let hasChanges = false;
211
+ let hasMjsRule = false;
212
+ let rulesProperty: recast.types.namedTypes.Property | null = null;
213
+
214
+ // Find the rules property
215
+ for (const prop of moduleObject.properties) {
216
+ if (!prop || (prop.type !== 'Property' && prop.type !== 'ObjectProperty')) {
217
+ continue;
218
+ }
219
+
220
+ const key = 'key' in prop ? prop.key : null;
221
+ const value = 'value' in prop ? prop.value : null;
222
+
223
+ if (key && key.type === 'Identifier' && key.name === 'rules' && value && value.type === 'ArrayExpression') {
224
+ rulesProperty = prop as recast.types.namedTypes.Property;
225
+ // Check if .mjs rule already exists
226
+ for (const element of value.elements) {
227
+ if (element && element.type === 'ObjectExpression' && element.properties) {
228
+ for (const ruleProp of element.properties) {
229
+ if (
230
+ ruleProp &&
231
+ (ruleProp.type === 'Property' || ruleProp.type === 'ObjectProperty') &&
232
+ 'key' in ruleProp &&
233
+ ruleProp.key.type === 'Identifier' &&
234
+ ruleProp.key.name === 'test'
235
+ ) {
236
+ const testValue = 'value' in ruleProp ? ruleProp.value : null;
237
+ if (testValue) {
238
+ // Check for RegExpLiteral with .mjs pattern
239
+ if (testValue.type === 'RegExpLiteral' && 'pattern' in testValue && testValue.pattern === '\\.mjs$') {
240
+ hasMjsRule = true;
241
+ break;
242
+ }
243
+ // Check for Literal with regex property
244
+ if (
245
+ testValue.type === 'Literal' &&
246
+ 'regex' in testValue &&
247
+ testValue.regex &&
248
+ typeof testValue.regex === 'object' &&
249
+ 'pattern' in testValue.regex &&
250
+ testValue.regex.pattern === '\\.mjs$'
251
+ ) {
252
+ hasMjsRule = true;
253
+ break;
254
+ }
255
+ // Check for string literal containing .mjs
256
+ if (
257
+ testValue.type === 'Literal' &&
258
+ 'value' in testValue &&
259
+ typeof testValue.value === 'string' &&
260
+ testValue.value.includes('.mjs')
261
+ ) {
262
+ hasMjsRule = true;
263
+ break;
264
+ }
265
+ }
266
+ }
267
+ }
268
+ }
269
+ if (hasMjsRule) {
270
+ break;
271
+ }
272
+ }
273
+ break;
274
+ }
275
+ }
276
+
277
+ // Add .mjs rule if missing (insert at position 1, after imports-loader rule which must be first)
278
+ if (!hasMjsRule && rulesProperty && 'value' in rulesProperty) {
279
+ const rulesArray = rulesProperty.value as recast.types.namedTypes.ArrayExpression;
280
+ const mjsRule = builders.objectExpression([
281
+ builders.property('init', builders.identifier('test'), builders.literal(/\.mjs$/)),
282
+ builders.property('init', builders.identifier('include'), builders.literal(/node_modules/)),
283
+ builders.property(
284
+ 'init',
285
+ builders.identifier('resolve'),
286
+ builders.objectExpression([
287
+ builders.property('init', builders.identifier('fullySpecified'), builders.literal(false)),
288
+ ])
289
+ ),
290
+ builders.property('init', builders.identifier('type'), builders.literal('javascript/auto')),
291
+ ]);
292
+ // Insert at position 1 (second position) to keep imports-loader first
293
+ rulesArray.elements.splice(1, 0, mjsRule);
294
+ hasChanges = true;
295
+ additionsDebug('Added module rule for .mjs files in node_modules with resolve.fullySpecified: false');
296
+ }
297
+
298
+ return hasChanges;
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Ensures plugin.json has grafanaDependency >= 10.2.0
304
+ * Bundling @grafana/ui is only supported from Grafana 10.2.0 onwards
305
+ */
306
+ function ensureMinGrafanaVersion(context: Context): void {
307
+ if (!context.doesFileExist(PLUGIN_JSON_PATH)) {
308
+ additionsDebug(`${PLUGIN_JSON_PATH} not found, skipping version check`);
309
+ return;
310
+ }
311
+
312
+ const pluginJsonRaw = context.getFile(PLUGIN_JSON_PATH);
313
+ if (!pluginJsonRaw) {
314
+ return;
315
+ }
316
+
317
+ try {
318
+ const pluginJson = JSON.parse(pluginJsonRaw);
319
+
320
+ if (!pluginJson.dependencies) {
321
+ pluginJson.dependencies = {};
322
+ }
323
+
324
+ const currentGrafanaDep = pluginJson.dependencies.grafanaDependency || '>=9.0.0';
325
+ const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, ''));
326
+ const minVersion = coerce(MIN_GRAFANA_VERSION);
327
+
328
+ if (!currentVersion || !minVersion || !gte(currentVersion, minVersion)) {
329
+ const oldVersion = pluginJson.dependencies.grafanaDependency || 'not set';
330
+ pluginJson.dependencies.grafanaDependency = `>=${MIN_GRAFANA_VERSION}`;
331
+ context.updateFile(PLUGIN_JSON_PATH, JSON.stringify(pluginJson, null, 2));
332
+ additionsDebug(
333
+ `Updated grafanaDependency from "${oldVersion}" to ">=${MIN_GRAFANA_VERSION}" - bundling @grafana/ui requires Grafana ${MIN_GRAFANA_VERSION} or higher`
334
+ );
335
+ console.log(
336
+ `\n⚠️ Updated grafanaDependency to ">=${MIN_GRAFANA_VERSION}" because bundling @grafana/ui is only supported from Grafana ${MIN_GRAFANA_VERSION} onwards.\n`
337
+ );
338
+ } else {
339
+ additionsDebug(
340
+ `grafanaDependency "${currentGrafanaDep}" already meets minimum requirement of ${MIN_GRAFANA_VERSION}`
341
+ );
342
+ }
343
+ } catch (error) {
344
+ additionsDebug(`Error updating ${PLUGIN_JSON_PATH}:`, error);
345
+ }
346
+ }
@@ -0,0 +1,180 @@
1
+ import * as recast from 'recast';
2
+ import * as typeScriptParser from 'recast/parsers/typescript.js';
3
+
4
+ import type { Context } from './context.js';
5
+ import { additionsDebug } from './utils.js';
6
+
7
+ const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts';
8
+ const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts';
9
+
10
+ /**
11
+ * Type for a function that modifies a resolve object expression
12
+ * @param resolveObject - The AST node representing the resolve configuration
13
+ * @returns true if changes were made, false otherwise
14
+ */
15
+ export type ResolveModifier = (resolveObject: recast.types.namedTypes.ObjectExpression) => boolean;
16
+
17
+ /**
18
+ * Type for a function that modifies a module rules array
19
+ * @param moduleObject - The AST node representing the module configuration
20
+ * @returns true if changes were made, false otherwise
21
+ */
22
+ export type ModuleRulesModifier = (moduleObject: recast.types.namedTypes.ObjectExpression) => boolean;
23
+
24
+ /**
25
+ * Updates the bundler's resolve and module configuration.
26
+ *
27
+ * This utility handles both webpack and rspack configurations, preferring rspack when both exist.
28
+ *
29
+ * @param context - The codemod context
30
+ * @param resolveModifier - Optional function to modify the resolve configuration
31
+ * @param moduleRulesModifier - Optional function to modify the module rules configuration
32
+ */
33
+ export function updateBundlerConfig(
34
+ context: Context,
35
+ resolveModifier?: ResolveModifier,
36
+ moduleRulesModifier?: ModuleRulesModifier
37
+ ): void {
38
+ if (!resolveModifier && !moduleRulesModifier) {
39
+ return;
40
+ }
41
+
42
+ // Try rspack config first (newer structure)
43
+ if (context.doesFileExist(RSPACK_CONFIG_PATH)) {
44
+ additionsDebug(`Found ${RSPACK_CONFIG_PATH}, updating bundler configuration...`);
45
+ const rspackContent = context.getFile(RSPACK_CONFIG_PATH);
46
+ if (rspackContent) {
47
+ try {
48
+ const ast = recast.parse(rspackContent, {
49
+ parser: typeScriptParser,
50
+ });
51
+
52
+ let hasChanges = false;
53
+
54
+ recast.visit(ast, {
55
+ visitObjectExpression(path) {
56
+ const { node } = path;
57
+ const properties = node.properties;
58
+
59
+ if (properties) {
60
+ for (const prop of properties) {
61
+ if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) {
62
+ const key = 'key' in prop ? prop.key : null;
63
+ const value = 'value' in prop ? prop.value : null;
64
+
65
+ // Find the resolve property
66
+ if (
67
+ resolveModifier &&
68
+ key &&
69
+ key.type === 'Identifier' &&
70
+ key.name === 'resolve' &&
71
+ value &&
72
+ value.type === 'ObjectExpression'
73
+ ) {
74
+ hasChanges = resolveModifier(value) || hasChanges;
75
+ }
76
+
77
+ // Find the module property
78
+ if (
79
+ moduleRulesModifier &&
80
+ key &&
81
+ key.type === 'Identifier' &&
82
+ key.name === 'module' &&
83
+ value &&
84
+ value.type === 'ObjectExpression'
85
+ ) {
86
+ hasChanges = moduleRulesModifier(value) || hasChanges;
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ return this.traverse(path);
93
+ },
94
+ });
95
+
96
+ if (hasChanges) {
97
+ const output = recast.print(ast, {
98
+ tabWidth: 2,
99
+ trailingComma: true,
100
+ lineTerminator: '\n',
101
+ });
102
+ context.updateFile(RSPACK_CONFIG_PATH, output.code);
103
+ additionsDebug(`Updated ${RSPACK_CONFIG_PATH}`);
104
+ }
105
+ } catch (error) {
106
+ additionsDebug(`Error updating ${RSPACK_CONFIG_PATH}:`, error);
107
+ }
108
+ }
109
+ return;
110
+ }
111
+
112
+ // Fall back to webpack config (legacy structure)
113
+ if (context.doesFileExist(WEBPACK_CONFIG_PATH)) {
114
+ additionsDebug(`Found ${WEBPACK_CONFIG_PATH}, updating bundler configuration...`);
115
+ const webpackContent = context.getFile(WEBPACK_CONFIG_PATH);
116
+ if (webpackContent) {
117
+ try {
118
+ const ast = recast.parse(webpackContent, {
119
+ parser: typeScriptParser,
120
+ });
121
+
122
+ let hasChanges = false;
123
+
124
+ recast.visit(ast, {
125
+ visitObjectExpression(path) {
126
+ const { node } = path;
127
+ const properties = node.properties;
128
+
129
+ if (properties) {
130
+ for (const prop of properties) {
131
+ if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) {
132
+ const key = 'key' in prop ? prop.key : null;
133
+ const value = 'value' in prop ? prop.value : null;
134
+
135
+ // Find the resolve property
136
+ if (
137
+ resolveModifier &&
138
+ key &&
139
+ key.type === 'Identifier' &&
140
+ key.name === 'resolve' &&
141
+ value &&
142
+ value.type === 'ObjectExpression'
143
+ ) {
144
+ hasChanges = resolveModifier(value) || hasChanges;
145
+ }
146
+
147
+ // Find the module property
148
+ if (
149
+ moduleRulesModifier &&
150
+ key &&
151
+ key.type === 'Identifier' &&
152
+ key.name === 'module' &&
153
+ value &&
154
+ value.type === 'ObjectExpression'
155
+ ) {
156
+ hasChanges = moduleRulesModifier(value) || hasChanges;
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ return this.traverse(path);
163
+ },
164
+ });
165
+
166
+ if (hasChanges) {
167
+ const output = recast.print(ast, {
168
+ tabWidth: 2,
169
+ trailingComma: true,
170
+ lineTerminator: '\n',
171
+ });
172
+ context.updateFile(WEBPACK_CONFIG_PATH, output.code);
173
+ additionsDebug(`Updated ${WEBPACK_CONFIG_PATH}`);
174
+ }
175
+ } catch (error) {
176
+ additionsDebug(`Error updating ${WEBPACK_CONFIG_PATH}:`, error);
177
+ }
178
+ }
179
+ }
180
+ }
@@ -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
+ });