@grafana/create-plugin 6.5.0-canary.2320.20231018478.0 → 6.5.0-canary.2320.20262762522.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.
@@ -1,9 +1,4 @@
1
1
  var defaultAdditions = [
2
- {
3
- name: "example-addition",
4
- description: "Adds an example addition to the plugin",
5
- scriptPath: import.meta.resolve("./scripts/example-addition.js")
6
- },
7
2
  {
8
3
  name: "i18n",
9
4
  description: "Adds internationalization (i18n) support to the plugin",
@@ -3,7 +3,7 @@ import { additionsDebug } from '../../../utils.js';
3
3
  import { updateDockerCompose, updatePluginJson, createI18nextConfig, ensureI18nextExternal } from './config-updates.js';
4
4
  import { addI18nInitialization, createLoadResourcesFile } from './code-generation.js';
5
5
  import { addI18nDependency, addSemverDependency, updateEslintConfig, addI18nextCli } from './tooling.js';
6
- import { checkNeedsBackwardCompatibility, createLocaleFiles } from './utils.js';
6
+ import { checkReactVersion, checkNeedsBackwardCompatibility, createLocaleFiles } from './utils.js';
7
7
 
8
8
  const schema = v.object(
9
9
  {
@@ -27,6 +27,7 @@ const schema = v.object(
27
27
  function i18nAddition(context, options) {
28
28
  const { locales } = options;
29
29
  additionsDebug("Adding i18n support with locales:", locales);
30
+ checkReactVersion(context);
30
31
  const needsBackwardCompatibility = checkNeedsBackwardCompatibility(context);
31
32
  additionsDebug("Needs backward compatibility:", needsBackwardCompatibility);
32
33
  updateDockerCompose(context);
@@ -48,6 +49,15 @@ function i18nAddition(context, options) {
48
49
  } catch (error) {
49
50
  additionsDebug(`Error ensuring i18next external: ${error instanceof Error ? error.message : String(error)}`);
50
51
  }
52
+ console.log("\n\u2705 i18n support has been successfully added to your plugin!\n");
53
+ console.log("Next steps:");
54
+ console.log("1. Follow the instructions to translate your source code:");
55
+ console.log(
56
+ " https://grafana.com/developers/plugin-tools/how-to-guides/plugin-internationalization-grafana-11#determine-the-text-to-translate"
57
+ );
58
+ console.log("2. Run the i18n-extract script to scan your code for translatable strings:");
59
+ console.log(" npm run i18n-extract (or yarn/pnpm run i18n-extract)");
60
+ console.log("3. Fill in your locale JSON files with translated strings\n");
51
61
  return context;
52
62
  }
53
63
 
@@ -1,6 +1,33 @@
1
1
  import { coerce, gte } from 'semver';
2
2
  import { additionsDebug } from '../../../utils.js';
3
3
 
4
+ function checkReactVersion(context) {
5
+ const packageJsonRaw = context.getFile("package.json");
6
+ if (!packageJsonRaw) {
7
+ return;
8
+ }
9
+ try {
10
+ const packageJson = JSON.parse(packageJsonRaw);
11
+ const reactVersion = packageJson.dependencies?.react || packageJson.devDependencies?.react || packageJson.peerDependencies?.react;
12
+ if (reactVersion) {
13
+ const reactVersionStr = reactVersion.replace(/[^0-9.]/g, "");
14
+ const reactVersionCoerced = coerce(reactVersionStr);
15
+ if (reactVersionCoerced && !gte(reactVersionCoerced, "18.0.0")) {
16
+ throw new Error(
17
+ `@grafana/i18n requires React 18 or higher. Your plugin is using React ${reactVersion}.
18
+
19
+ Please upgrade to React 18+ to use i18n support.
20
+ Update your package.json to use "react": "^18.3.0" and "react-dom": "^18.3.0".`
21
+ );
22
+ }
23
+ }
24
+ } catch (error) {
25
+ if (error instanceof Error && error.message.includes("@grafana/i18n requires React")) {
26
+ throw error;
27
+ }
28
+ additionsDebug("Error checking React version:", error);
29
+ }
30
+ }
4
31
  function checkNeedsBackwardCompatibility(context) {
5
32
  const pluginJsonRaw = context.getFile("src/plugin.json");
6
33
  if (!pluginJsonRaw) {
@@ -47,4 +74,4 @@ function createLocaleFiles(context, locales) {
47
74
  }
48
75
  }
49
76
 
50
- export { checkNeedsBackwardCompatibility, createLocaleFiles };
77
+ export { checkNeedsBackwardCompatibility, checkReactVersion, createLocaleFiles };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafana/create-plugin",
3
- "version": "6.5.0-canary.2320.20231018478.0",
3
+ "version": "6.5.0-canary.2320.20262762522.0",
4
4
  "repository": {
5
5
  "directory": "packages/create-plugin",
6
6
  "url": "https://github.com/grafana/plugin-tools"
@@ -56,5 +56,5 @@
56
56
  "engines": {
57
57
  "node": ">=20"
58
58
  },
59
- "gitHead": "2c9b13a41672a1b9e24a92b61a39533bcee266a6"
59
+ "gitHead": "1df74d43ab143d2868f8dc98703faf6f4b120fa9"
60
60
  }
@@ -1,11 +1,6 @@
1
1
  import { Codemod } from '../types.js';
2
2
 
3
3
  export default [
4
- {
5
- name: 'example-addition',
6
- description: 'Adds an example addition to the plugin',
7
- scriptPath: import.meta.resolve('./scripts/example-addition.js'),
8
- },
9
4
  {
10
5
  name: 'i18n',
11
6
  description: 'Adds internationalization (i18n) support to the plugin',
@@ -8,6 +8,11 @@ Adds internationalization (i18n) support to a Grafana plugin.
8
8
  npx @grafana/create-plugin add i18n --locales <locales>
9
9
  ```
10
10
 
11
+ ## Requirements
12
+
13
+ - **Grafana >= 11.0.0**: i18n is not supported for Grafana versions prior to 11.0.0. If your plugin's `grafanaDependency` is set to a version < 11.0.0, the script will automatically update it to `>=11.0.0`.
14
+ - **React >= 18**: The `@grafana/i18n` package requires React 18 or higher. If your plugin uses React < 18, the script will exit with an error and prompt you to upgrade.
15
+
11
16
  ## Required Flags
12
17
 
13
18
  ### `--locales`
@@ -24,6 +29,12 @@ npx @grafana/create-plugin add i18n --locales en-US,es-ES,sv-SE
24
29
 
25
30
  ## What This Addition Does
26
31
 
32
+ **Important:** This script sets up the infrastructure and configuration needed for translations. After running this script, you'll need to:
33
+
34
+ 1. Mark up your code with translation functions (`t()` and `<Trans>`)
35
+ 2. Run `npm run i18n-extract` to extract translatable strings
36
+ 3. Fill in the locale JSON files with translated strings
37
+
27
38
  This addition configures your plugin for internationalization by:
28
39
 
29
40
  1. **Updating `docker-compose.yaml`** - Adds the `localizationForPlugins` feature toggle to your local Grafana instance
@@ -39,9 +50,11 @@ This addition configures your plugin for internationalization by:
39
50
 
40
51
  ## Backward Compatibility
41
52
 
53
+ **Note:** i18n is not supported for Grafana versions prior to 11.0.0.
54
+
42
55
  The addition automatically detects your plugin's `grafanaDependency` version:
43
56
 
44
- ### Grafana >= 12.1.0 (Modern)
57
+ ### Grafana >= 12.1.0
45
58
 
46
59
  - Sets `grafanaDependency` to `>=12.1.0`
47
60
  - Grafana handles loading translations automatically
@@ -49,7 +62,7 @@ The addition automatically detects your plugin's `grafanaDependency` version:
49
62
  - No `loadResources.ts` file needed
50
63
  - No `semver` dependency needed
51
64
 
52
- ### Grafana 11.0.0 - 12.0.x (Backward Compatible)
65
+ ### Grafana 11.0.0 - 12.0.x
53
66
 
54
67
  - Keeps or sets `grafanaDependency` to `>=11.0.0`
55
68
  - Plugin handles loading translations
@@ -57,9 +70,9 @@ The addition automatically detects your plugin's `grafanaDependency` version:
57
70
  - Adds runtime version check using `semver`
58
71
  - Initialization with loaders: `await initPluginTranslations(pluginJson.id, loaders)`
59
72
 
60
- ## Running Multiple Times (Idempotent)
73
+ ## Running Multiple Times
61
74
 
62
- This addition is **defensive** and can be run multiple times safely. Each operation checks if it's already been done:
75
+ This addition can be run multiple times safely. It uses defensive programming to check if configurations already exist before adding them, preventing duplicates and overwrites:
63
76
 
64
77
  ### Adding New Locales
65
78
 
@@ -75,19 +88,9 @@ npx @grafana/create-plugin add i18n --locales en-US,es-ES,sv-SE
75
88
 
76
89
  The addition will:
77
90
 
78
- - Merge new locales into `plugin.json` without duplicates
79
- - Create only the new locale files (won't overwrite existing ones)
80
- - Skip updating files that already have i18n configured
81
-
82
- ### What Won't Be Duplicated
83
-
84
- - **Locale files**: Existing locale JSON files are never overwritten (preserves your translations)
85
- - **Dependencies**: Won't re-add dependencies that already exist
86
- - **ESLint config**: Won't duplicate the i18n plugin import or rules
87
- - **Module initialization**: Won't add `initPluginTranslations` if it's already present
88
- - **Support files**: Won't overwrite `i18next.config.ts` or `loadResources.ts` if they exist
89
- - **npm scripts**: Won't overwrite the `i18n-extract` script if it exists
90
- - **Docker feature toggle**: Won't duplicate the feature toggle
91
+ - Merge new locales into `plugin.json` without duplicates
92
+ - Create only the new locale files (won't overwrite existing ones)
93
+ - Skip updating files that already have i18n configured
91
94
 
92
95
  ## Files Created
93
96
 
@@ -126,9 +129,7 @@ your-plugin/
126
129
 
127
130
  After running this addition:
128
131
 
129
- 1. **Extract translations**: Run `npm run i18n-extract` to scan your code for translatable strings
130
- 2. **Add translations**: Fill in your locale JSON files with translated strings
131
- 3. **Use in code**: Import and use the translation functions:
132
+ 1. **Use in code**: Import and use the translation functions to mark up your code:
132
133
 
133
134
  ```typescript
134
135
  import { t, Trans } from '@grafana/i18n';
@@ -142,6 +143,9 @@ After running this addition:
142
143
  </Trans>
143
144
  ```
144
145
 
146
+ 2. **Extract translations**: Run `npm run i18n-extract` to scan your code for translatable strings
147
+ 3. **Add translations**: Fill in your locale JSON files with translated strings
148
+
145
149
  ## Debug Output
146
150
 
147
151
  Enable debug logging to see what the addition is doing:
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { Context } from '../../../context.js';
4
+ import { ensureI18nextExternal, updatePluginJson } from './config-updates.js';
5
+
6
+ describe('config-updates', () => {
7
+ describe('ensureI18nextExternal', () => {
8
+ it('should add i18next to externals array in .config/bundler/externals.ts', () => {
9
+ const context = new Context('/virtual');
10
+
11
+ context.addFile('.config/bundler/externals.ts', `export const externals = ['react', 'react-dom'];`);
12
+
13
+ ensureI18nextExternal(context);
14
+
15
+ const externalsContent = context.getFile('.config/bundler/externals.ts');
16
+ expect(externalsContent).toMatch(/["']i18next["']/);
17
+ expect(externalsContent).toContain("'react'");
18
+ expect(externalsContent).toContain("'react-dom'");
19
+ });
20
+
21
+ it('should not duplicate i18next if already in externals array', () => {
22
+ const context = new Context('/virtual');
23
+
24
+ const originalExternals = `export const externals = ['react', 'i18next', 'react-dom'];`;
25
+ context.addFile('.config/bundler/externals.ts', originalExternals);
26
+
27
+ ensureI18nextExternal(context);
28
+
29
+ const externalsContent = context.getFile('.config/bundler/externals.ts');
30
+ const i18nextCount = (externalsContent?.match(/["']i18next["']/g) || []).length;
31
+ expect(i18nextCount).toBe(1);
32
+ });
33
+
34
+ it('should add i18next to externals in .config/webpack/webpack.config.ts (legacy)', () => {
35
+ const context = new Context('/virtual');
36
+
37
+ context.addFile(
38
+ '.config/webpack/webpack.config.ts',
39
+ `import { Configuration } from 'webpack';
40
+ export const config: Configuration = {
41
+ externals: ['react', 'react-dom'],
42
+ };`
43
+ );
44
+
45
+ ensureI18nextExternal(context);
46
+
47
+ const webpackConfig = context.getFile('.config/webpack/webpack.config.ts');
48
+ expect(webpackConfig).toMatch(/["']i18next["']/);
49
+ expect(webpackConfig).toContain("'react'");
50
+ expect(webpackConfig).toContain("'react-dom'");
51
+ });
52
+
53
+ it('should handle missing externals configuration gracefully', () => {
54
+ const context = new Context('/virtual');
55
+ // No externals.ts or webpack.config.ts
56
+
57
+ expect(() => {
58
+ ensureI18nextExternal(context);
59
+ }).not.toThrow();
60
+ });
61
+
62
+ it('should prefer .config/bundler/externals.ts over webpack.config.ts', () => {
63
+ const context = new Context('/virtual');
64
+
65
+ context.addFile('.config/bundler/externals.ts', `export const externals = ['react'];`);
66
+ context.addFile('.config/webpack/webpack.config.ts', `export const config = { externals: ['react-dom'] };`);
67
+
68
+ ensureI18nextExternal(context);
69
+
70
+ // Should update externals.ts, not webpack.config.ts
71
+ const externalsContent = context.getFile('.config/bundler/externals.ts');
72
+ expect(externalsContent).toMatch(/["']i18next["']/);
73
+
74
+ const webpackConfig = context.getFile('.config/webpack/webpack.config.ts');
75
+ expect(webpackConfig).not.toMatch(/["']i18next["']/);
76
+ });
77
+ });
78
+
79
+ describe('updatePluginJson', () => {
80
+ it('should auto-update grafanaDependency from < 11.0.0 to >=11.0.0', () => {
81
+ const context = new Context('/virtual');
82
+
83
+ context.addFile(
84
+ 'src/plugin.json',
85
+ JSON.stringify({
86
+ id: 'test-plugin',
87
+ type: 'panel',
88
+ name: 'Test Plugin',
89
+ dependencies: {
90
+ grafanaDependency: '>=10.0.0',
91
+ },
92
+ })
93
+ );
94
+
95
+ updatePluginJson(context, ['en-US'], true);
96
+
97
+ const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}');
98
+ expect(pluginJson.dependencies.grafanaDependency).toBe('>=11.0.0');
99
+ expect(pluginJson.languages).toEqual(['en-US']);
100
+ });
101
+
102
+ it('should keep grafanaDependency >= 11.0.0 unchanged when needsBackwardCompatibility is true', () => {
103
+ const context = new Context('/virtual');
104
+
105
+ context.addFile(
106
+ 'src/plugin.json',
107
+ JSON.stringify({
108
+ id: 'test-plugin',
109
+ type: 'panel',
110
+ name: 'Test Plugin',
111
+ dependencies: {
112
+ grafanaDependency: '>=11.0.0',
113
+ },
114
+ })
115
+ );
116
+
117
+ updatePluginJson(context, ['en-US'], true);
118
+
119
+ const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}');
120
+ expect(pluginJson.dependencies.grafanaDependency).toBe('>=11.0.0');
121
+ });
122
+
123
+ it('should update grafanaDependency to >=12.1.0 when needsBackwardCompatibility is false', () => {
124
+ const context = new Context('/virtual');
125
+
126
+ context.addFile(
127
+ 'src/plugin.json',
128
+ JSON.stringify({
129
+ id: 'test-plugin',
130
+ type: 'panel',
131
+ name: 'Test Plugin',
132
+ dependencies: {
133
+ grafanaDependency: '>=11.0.0',
134
+ },
135
+ })
136
+ );
137
+
138
+ updatePluginJson(context, ['en-US'], false);
139
+
140
+ const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}');
141
+ expect(pluginJson.dependencies.grafanaDependency).toBe('>=12.1.0');
142
+ });
143
+
144
+ it('should merge locales with existing languages', () => {
145
+ const context = new Context('/virtual');
146
+
147
+ context.addFile(
148
+ 'src/plugin.json',
149
+ JSON.stringify({
150
+ id: 'test-plugin',
151
+ type: 'panel',
152
+ name: 'Test Plugin',
153
+ languages: ['en-US'],
154
+ dependencies: {
155
+ grafanaDependency: '>=12.1.0',
156
+ },
157
+ })
158
+ );
159
+
160
+ updatePluginJson(context, ['es-ES', 'sv-SE'], false);
161
+
162
+ const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}');
163
+ expect(pluginJson.languages).toEqual(['en-US', 'es-ES', 'sv-SE']);
164
+ });
165
+
166
+ it('should not duplicate locales', () => {
167
+ const context = new Context('/virtual');
168
+
169
+ context.addFile(
170
+ 'src/plugin.json',
171
+ JSON.stringify({
172
+ id: 'test-plugin',
173
+ type: 'panel',
174
+ name: 'Test Plugin',
175
+ languages: ['en-US', 'es-ES'],
176
+ dependencies: {
177
+ grafanaDependency: '>=12.1.0',
178
+ },
179
+ })
180
+ );
181
+
182
+ updatePluginJson(context, ['en-US', 'sv-SE'], false);
183
+
184
+ const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}');
185
+ expect(pluginJson.languages).toEqual(['en-US', 'es-ES', 'sv-SE']);
186
+ });
187
+
188
+ it('should not update grafanaDependency if it is already >= target version', () => {
189
+ const context = new Context('/virtual');
190
+
191
+ context.addFile(
192
+ 'src/plugin.json',
193
+ JSON.stringify({
194
+ id: 'test-plugin',
195
+ type: 'panel',
196
+ name: 'Test Plugin',
197
+ dependencies: {
198
+ grafanaDependency: '>=13.0.0',
199
+ },
200
+ })
201
+ );
202
+
203
+ updatePluginJson(context, ['en-US'], false);
204
+
205
+ const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}');
206
+ expect(pluginJson.dependencies.grafanaDependency).toBe('>=13.0.0');
207
+ });
208
+ });
209
+ });
@@ -372,47 +372,6 @@ describe('i18n addition', () => {
372
372
  expect(packageJson.scripts['i18n-extract']).toBe('i18next-cli extract --sync-primary');
373
373
  });
374
374
 
375
- it('should not add ESLint config if already present', () => {
376
- const context = new Context('/virtual');
377
-
378
- context.addFile(
379
- 'src/plugin.json',
380
- JSON.stringify({
381
- id: 'test-plugin',
382
- type: 'panel',
383
- name: 'Test Plugin',
384
- dependencies: {
385
- grafanaDependency: '>=12.1.0',
386
- },
387
- })
388
- );
389
- context.addFile(
390
- 'docker-compose.yaml',
391
- `services:
392
- grafana:
393
- environment:
394
- FOO: bar`
395
- );
396
- context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} }));
397
- context.addFile(
398
- 'eslint.config.mjs',
399
- 'import { defineConfig } from "eslint/config";\nimport grafanaI18nPlugin from "@grafana/i18n/eslint-plugin";\nexport default defineConfig([]);'
400
- );
401
- context.addFile(
402
- 'src/module.ts',
403
- 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();'
404
- );
405
-
406
- const result = i18nAddition(context, { locales: ['en-US'] });
407
-
408
- // The ESLint config should remain unchanged
409
- const eslintConfig = result.getFile('eslint.config.mjs');
410
- expect(eslintConfig).toContain('@grafana/i18n/eslint-plugin');
411
- // Should not have duplicate imports or configs
412
- const importCount = (eslintConfig?.match(/@grafana\/i18n\/eslint-plugin/g) || []).length;
413
- expect(importCount).toBe(1);
414
- });
415
-
416
375
  it('should not create locale files if they already exist', () => {
417
376
  const context = new Context('/virtual');
418
377
 
@@ -712,59 +671,4 @@ export const plugin = new PanelPlugin();`;
712
671
  const toggleCount = (dockerCompose?.match(/localizationForPlugins/g) || []).length;
713
672
  expect(toggleCount).toBe(1);
714
673
  });
715
-
716
- it('should add correct ESLint config with proper rules and options', () => {
717
- const context = new Context('/virtual');
718
-
719
- context.addFile(
720
- 'src/plugin.json',
721
- JSON.stringify({
722
- id: 'test-plugin',
723
- type: 'panel',
724
- name: 'Test Plugin',
725
- dependencies: {
726
- grafanaDependency: '>=12.1.0',
727
- },
728
- })
729
- );
730
- context.addFile(
731
- 'docker-compose.yaml',
732
- `services:
733
- grafana:
734
- environment:
735
- FOO: bar`
736
- );
737
- context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} }));
738
- context.addFile(
739
- 'eslint.config.mjs',
740
- 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);'
741
- );
742
- context.addFile(
743
- 'src/module.ts',
744
- 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();'
745
- );
746
-
747
- const result = i18nAddition(context, { locales: ['en-US'] });
748
-
749
- const eslintConfig = result.getFile('eslint.config.mjs');
750
-
751
- // Check correct import (recast uses double quotes)
752
- expect(eslintConfig).toContain('import grafanaI18nPlugin from "@grafana/i18n/eslint-plugin"');
753
-
754
- // Check plugin registration
755
- expect(eslintConfig).toContain('"@grafana/i18n": grafanaI18nPlugin');
756
-
757
- // Check rules are present
758
- expect(eslintConfig).toContain('"@grafana/i18n/no-untranslated-strings"');
759
- expect(eslintConfig).toContain('"@grafana/i18n/no-translation-top-level"');
760
-
761
- // Check rule configuration
762
- expect(eslintConfig).toContain('"error"');
763
- expect(eslintConfig).toContain('calleesToIgnore');
764
- expect(eslintConfig).toContain('"^css$"');
765
- expect(eslintConfig).toContain('"use[A-Z].*"');
766
-
767
- // Check config name
768
- expect(eslintConfig).toContain('name: "grafana/i18n-rules"');
769
- });
770
674
  });
@@ -5,7 +5,7 @@ import { additionsDebug } from '../../../utils.js';
5
5
  import { updateDockerCompose, updatePluginJson, createI18nextConfig, ensureI18nextExternal } from './config-updates.js';
6
6
  import { addI18nInitialization, createLoadResourcesFile } from './code-generation.js';
7
7
  import { updateEslintConfig, addI18nDependency, addSemverDependency, addI18nextCli } from './tooling.js';
8
- import { checkNeedsBackwardCompatibility, createLocaleFiles } from './utils.js';
8
+ import { checkNeedsBackwardCompatibility, createLocaleFiles, checkReactVersion } from './utils.js';
9
9
 
10
10
  /**
11
11
  * I18n addition schema using Valibot
@@ -39,6 +39,9 @@ export default function i18nAddition(context: Context, options: I18nOptions): Co
39
39
 
40
40
  additionsDebug('Adding i18n support with locales:', locales);
41
41
 
42
+ // Check React version early - @grafana/i18n requires React 18+
43
+ checkReactVersion(context);
44
+
42
45
  // Determine if we need backward compatibility (Grafana < 12.1.0)
43
46
  const needsBackwardCompatibility = checkNeedsBackwardCompatibility(context);
44
47
  additionsDebug('Needs backward compatibility:', needsBackwardCompatibility);
@@ -84,5 +87,16 @@ export default function i18nAddition(context: Context, options: I18nOptions): Co
84
87
  additionsDebug(`Error ensuring i18next external: ${error instanceof Error ? error.message : String(error)}`);
85
88
  }
86
89
 
90
+ // Success message with next steps
91
+ console.log('\n✅ i18n support has been successfully added to your plugin!\n');
92
+ console.log('Next steps:');
93
+ console.log('1. Follow the instructions to translate your source code:');
94
+ console.log(
95
+ ' https://grafana.com/developers/plugin-tools/how-to-guides/plugin-internationalization-grafana-11#determine-the-text-to-translate'
96
+ );
97
+ console.log('2. Run the i18n-extract script to scan your code for translatable strings:');
98
+ console.log(' npm run i18n-extract (or yarn/pnpm run i18n-extract)');
99
+ console.log('3. Fill in your locale JSON files with translated strings\n');
100
+
87
101
  return context;
88
102
  }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { Context } from '../../../context.js';
4
+ import { updateEslintConfig } from './tooling.js';
5
+
6
+ describe('tooling', () => {
7
+ describe('updateEslintConfig', () => {
8
+ it('should add correct ESLint config with proper rules and options', () => {
9
+ const context = new Context('/virtual');
10
+
11
+ context.addFile(
12
+ 'eslint.config.mjs',
13
+ 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);'
14
+ );
15
+
16
+ updateEslintConfig(context);
17
+
18
+ const eslintConfig = context.getFile('eslint.config.mjs');
19
+
20
+ // Check correct import (recast uses double quotes)
21
+ expect(eslintConfig).toContain('import grafanaI18nPlugin from "@grafana/i18n/eslint-plugin"');
22
+
23
+ // Check plugin registration
24
+ expect(eslintConfig).toContain('"@grafana/i18n": grafanaI18nPlugin');
25
+
26
+ // Check rules are present
27
+ expect(eslintConfig).toContain('"@grafana/i18n/no-untranslated-strings"');
28
+ expect(eslintConfig).toContain('"@grafana/i18n/no-translation-top-level"');
29
+
30
+ // Check rule configuration
31
+ expect(eslintConfig).toContain('"error"');
32
+ expect(eslintConfig).toContain('calleesToIgnore');
33
+ expect(eslintConfig).toContain('"^css$"');
34
+ expect(eslintConfig).toContain('"use[A-Z].*"');
35
+
36
+ // Check config name
37
+ expect(eslintConfig).toContain('name: "grafana/i18n-rules"');
38
+ });
39
+
40
+ it('should not add ESLint config if already present', () => {
41
+ const context = new Context('/virtual');
42
+
43
+ context.addFile(
44
+ 'eslint.config.mjs',
45
+ 'import { defineConfig } from "eslint/config";\nimport grafanaI18nPlugin from "@grafana/i18n/eslint-plugin";\nexport default defineConfig([]);'
46
+ );
47
+
48
+ const originalContent = context.getFile('eslint.config.mjs');
49
+
50
+ updateEslintConfig(context);
51
+
52
+ // The ESLint config should remain unchanged
53
+ const eslintConfig = context.getFile('eslint.config.mjs');
54
+ expect(eslintConfig).toBe(originalContent);
55
+ expect(eslintConfig).toContain('@grafana/i18n/eslint-plugin');
56
+ // Should not have duplicate imports or configs
57
+ const importCount = (eslintConfig?.match(/@grafana\/i18n\/eslint-plugin/g) || []).length;
58
+ expect(importCount).toBe(1);
59
+ });
60
+
61
+ it('should handle missing eslint.config.mjs gracefully', () => {
62
+ const context = new Context('/virtual');
63
+ // No eslint.config.mjs file
64
+
65
+ expect(() => {
66
+ updateEslintConfig(context);
67
+ }).not.toThrow();
68
+ });
69
+
70
+ it('should handle empty eslint.config.mjs gracefully', () => {
71
+ const context = new Context('/virtual');
72
+
73
+ context.addFile('eslint.config.mjs', '');
74
+
75
+ expect(() => {
76
+ updateEslintConfig(context);
77
+ }).not.toThrow();
78
+ });
79
+ });
80
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { Context } from '../../../context.js';
4
+ import { checkReactVersion } from './utils.js';
5
+
6
+ describe('utils', () => {
7
+ describe('checkReactVersion', () => {
8
+ it('should throw error if React < 18 in dependencies', () => {
9
+ const context = new Context('/virtual');
10
+
11
+ context.addFile(
12
+ 'package.json',
13
+ JSON.stringify({
14
+ dependencies: {
15
+ react: '^17.0.2',
16
+ 'react-dom': '^17.0.2',
17
+ },
18
+ })
19
+ );
20
+
21
+ expect(() => {
22
+ checkReactVersion(context);
23
+ }).toThrow('@grafana/i18n requires React 18 or higher');
24
+ });
25
+
26
+ it('should throw error if React 17 in devDependencies', () => {
27
+ const context = new Context('/virtual');
28
+
29
+ context.addFile(
30
+ 'package.json',
31
+ JSON.stringify({
32
+ devDependencies: {
33
+ react: '^17.0.2',
34
+ 'react-dom': '^17.0.2',
35
+ },
36
+ })
37
+ );
38
+
39
+ expect(() => {
40
+ checkReactVersion(context);
41
+ }).toThrow('@grafana/i18n requires React 18 or higher');
42
+ });
43
+
44
+ it('should throw error if React 17 in peerDependencies', () => {
45
+ const context = new Context('/virtual');
46
+
47
+ context.addFile(
48
+ 'package.json',
49
+ JSON.stringify({
50
+ peerDependencies: {
51
+ react: '^17.0.2',
52
+ 'react-dom': '^17.0.2',
53
+ },
54
+ })
55
+ );
56
+
57
+ expect(() => {
58
+ checkReactVersion(context);
59
+ }).toThrow('@grafana/i18n requires React 18 or higher');
60
+ });
61
+
62
+ it('should continue if React >= 18', () => {
63
+ const context = new Context('/virtual');
64
+
65
+ context.addFile(
66
+ 'package.json',
67
+ JSON.stringify({
68
+ dependencies: {
69
+ react: '^18.3.0',
70
+ 'react-dom': '^18.3.0',
71
+ },
72
+ })
73
+ );
74
+
75
+ expect(() => {
76
+ checkReactVersion(context);
77
+ }).not.toThrow();
78
+ });
79
+
80
+ it('should continue if React version cannot be determined (no package.json)', () => {
81
+ const context = new Context('/virtual');
82
+ // No package.json file
83
+
84
+ expect(() => {
85
+ checkReactVersion(context);
86
+ }).not.toThrow();
87
+ });
88
+
89
+ it('should continue if React version cannot be determined (no React dependency)', () => {
90
+ const context = new Context('/virtual');
91
+
92
+ context.addFile('package.json', JSON.stringify({})); // No React dependency
93
+
94
+ expect(() => {
95
+ checkReactVersion(context);
96
+ }).not.toThrow();
97
+ });
98
+
99
+ it('should handle version ranges correctly', () => {
100
+ const context = new Context('/virtual');
101
+
102
+ context.addFile(
103
+ 'package.json',
104
+ JSON.stringify({
105
+ dependencies: {
106
+ react: '~18.1.0',
107
+ },
108
+ })
109
+ );
110
+
111
+ expect(() => {
112
+ checkReactVersion(context);
113
+ }).not.toThrow();
114
+ });
115
+
116
+ it('should handle React 19', () => {
117
+ const context = new Context('/virtual');
118
+
119
+ context.addFile(
120
+ 'package.json',
121
+ JSON.stringify({
122
+ dependencies: {
123
+ react: '^19.0.0',
124
+ },
125
+ })
126
+ );
127
+
128
+ expect(() => {
129
+ checkReactVersion(context);
130
+ }).not.toThrow();
131
+ });
132
+ });
133
+ });
@@ -3,6 +3,43 @@ import { coerce, gte } from 'semver';
3
3
  import type { Context } from '../../../context.js';
4
4
  import { additionsDebug } from '../../../utils.js';
5
5
 
6
+ /**
7
+ * Checks if React version is >= 18
8
+ * @throws Error if React < 18 (since @grafana/i18n requires React 18+)
9
+ */
10
+ export function checkReactVersion(context: Context): void {
11
+ const packageJsonRaw = context.getFile('package.json');
12
+ if (!packageJsonRaw) {
13
+ return;
14
+ }
15
+
16
+ try {
17
+ const packageJson = JSON.parse(packageJsonRaw);
18
+ const reactVersion =
19
+ packageJson.dependencies?.react || packageJson.devDependencies?.react || packageJson.peerDependencies?.react;
20
+
21
+ if (reactVersion) {
22
+ const reactVersionStr = reactVersion.replace(/[^0-9.]/g, '');
23
+ const reactVersionCoerced = coerce(reactVersionStr);
24
+
25
+ if (reactVersionCoerced && !gte(reactVersionCoerced, '18.0.0')) {
26
+ throw new Error(
27
+ `@grafana/i18n requires React 18 or higher. Your plugin is using React ${reactVersion}.\n\n` +
28
+ `Please upgrade to React 18+ to use i18n support.\n` +
29
+ `Update your package.json to use "react": "^18.3.0" and "react-dom": "^18.3.0".`
30
+ );
31
+ }
32
+ }
33
+ } catch (error) {
34
+ // If it's our version check error, re-throw it
35
+ if (error instanceof Error && error.message.includes('@grafana/i18n requires React')) {
36
+ throw error;
37
+ }
38
+ // Otherwise, just log and continue (can't determine React version)
39
+ additionsDebug('Error checking React version:', error);
40
+ }
41
+ }
42
+
6
43
  export function checkNeedsBackwardCompatibility(context: Context): boolean {
7
44
  const pluginJsonRaw = context.getFile('src/plugin.json');
8
45
  if (!pluginJsonRaw) {