@grafana/create-plugin 6.5.0-canary.2320.20227367050.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.
- package/dist/codemods/additions/additions.js +0 -5
- package/dist/codemods/additions/scripts/i18n/config-updates.js +158 -1
- package/dist/codemods/additions/scripts/i18n/index.js +17 -2
- package/dist/codemods/additions/scripts/i18n/utils.js +28 -1
- package/package.json +2 -2
- package/src/codemods/additions/additions.ts +0 -5
- package/src/codemods/additions/scripts/i18n/README.md +24 -20
- package/src/codemods/additions/scripts/i18n/config-updates.test.ts +209 -0
- package/src/codemods/additions/scripts/i18n/config-updates.ts +222 -0
- package/src/codemods/additions/scripts/i18n/index.test.ts +0 -96
- package/src/codemods/additions/scripts/i18n/index.ts +23 -2
- package/src/codemods/additions/scripts/i18n/tooling.test.ts +80 -0
- package/src/codemods/additions/scripts/i18n/utils.test.ts +133 -0
- package/src/codemods/additions/scripts/i18n/utils.ts +37 -0
|
@@ -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",
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { coerce, gte } from 'semver';
|
|
2
2
|
import { parseDocument, stringify } from 'yaml';
|
|
3
|
+
import * as recast from 'recast';
|
|
4
|
+
import * as typeScriptParser from 'recast/parsers/typescript.js';
|
|
3
5
|
import { additionsDebug } from '../../../utils.js';
|
|
4
6
|
|
|
7
|
+
const { builders } = recast.types;
|
|
5
8
|
function updateDockerCompose(context) {
|
|
6
9
|
if (!context.doesFileExist("docker-compose.yaml")) {
|
|
7
10
|
additionsDebug("docker-compose.yaml not found, skipping");
|
|
@@ -109,5 +112,159 @@ export default defineConfig({
|
|
|
109
112
|
additionsDebug("Error creating i18next.config.ts:", error);
|
|
110
113
|
}
|
|
111
114
|
}
|
|
115
|
+
function addI18nextToExternalsArray(externalsArray) {
|
|
116
|
+
const hasI18next = externalsArray.elements.some((element) => {
|
|
117
|
+
if (element && (element.type === "Literal" || element.type === "StringLiteral") && typeof element.value === "string") {
|
|
118
|
+
return element.value === "i18next";
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
});
|
|
122
|
+
if (hasI18next) {
|
|
123
|
+
additionsDebug("'i18next' already in externals array");
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
let insertIndex = -1;
|
|
127
|
+
for (let i = 0; i < externalsArray.elements.length; i++) {
|
|
128
|
+
const element = externalsArray.elements[i];
|
|
129
|
+
if (element && (element.type === "Literal" || element.type === "StringLiteral") && element.value === "rxjs") {
|
|
130
|
+
insertIndex = i + 1;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (insertIndex === -1) {
|
|
135
|
+
for (let i = externalsArray.elements.length - 1; i >= 0; i--) {
|
|
136
|
+
const element = externalsArray.elements[i];
|
|
137
|
+
if (element && element.type !== "FunctionExpression" && element.type !== "ArrowFunctionExpression") {
|
|
138
|
+
insertIndex = i + 1;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (insertIndex === -1) {
|
|
143
|
+
insertIndex = externalsArray.elements.length;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
externalsArray.elements.splice(insertIndex, 0, builders.literal("i18next"));
|
|
147
|
+
additionsDebug(`Added 'i18next' to externals array at position ${insertIndex}`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
function ensureI18nextExternal(context) {
|
|
151
|
+
try {
|
|
152
|
+
additionsDebug("Checking for externals configuration...");
|
|
153
|
+
const externalsPath = ".config/bundler/externals.ts";
|
|
154
|
+
if (context.doesFileExist(externalsPath)) {
|
|
155
|
+
additionsDebug(`Found ${externalsPath}, checking for i18next...`);
|
|
156
|
+
const externalsContent = context.getFile(externalsPath);
|
|
157
|
+
if (externalsContent) {
|
|
158
|
+
try {
|
|
159
|
+
const ast = recast.parse(externalsContent, {
|
|
160
|
+
parser: typeScriptParser
|
|
161
|
+
});
|
|
162
|
+
let hasChanges = false;
|
|
163
|
+
recast.visit(ast, {
|
|
164
|
+
visitVariableDeclarator(path) {
|
|
165
|
+
const { node } = path;
|
|
166
|
+
if (node.id.type === "Identifier" && node.id.name === "externals" && node.init && node.init.type === "ArrayExpression") {
|
|
167
|
+
additionsDebug("Found externals array in externals.ts");
|
|
168
|
+
if (addI18nextToExternalsArray(node.init)) {
|
|
169
|
+
hasChanges = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return this.traverse(path);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
if (hasChanges) {
|
|
176
|
+
const output = recast.print(ast, {
|
|
177
|
+
tabWidth: 2,
|
|
178
|
+
trailingComma: true,
|
|
179
|
+
lineTerminator: "\n"
|
|
180
|
+
});
|
|
181
|
+
context.updateFile(externalsPath, output.code);
|
|
182
|
+
additionsDebug(`Updated ${externalsPath} with i18next external`);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
additionsDebug(`Error updating ${externalsPath}:`, error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const webpackConfigPath = ".config/webpack/webpack.config.ts";
|
|
191
|
+
additionsDebug(`Checking for ${webpackConfigPath}...`);
|
|
192
|
+
if (context.doesFileExist(webpackConfigPath)) {
|
|
193
|
+
additionsDebug(`Found ${webpackConfigPath}, checking for inline externals...`);
|
|
194
|
+
const webpackContent = context.getFile(webpackConfigPath);
|
|
195
|
+
if (webpackContent) {
|
|
196
|
+
try {
|
|
197
|
+
const ast = recast.parse(webpackContent, {
|
|
198
|
+
parser: typeScriptParser
|
|
199
|
+
});
|
|
200
|
+
let hasChanges = false;
|
|
201
|
+
let foundExternals = false;
|
|
202
|
+
recast.visit(ast, {
|
|
203
|
+
visitObjectExpression(path) {
|
|
204
|
+
const { node } = path;
|
|
205
|
+
const properties = node.properties;
|
|
206
|
+
if (properties) {
|
|
207
|
+
for (const prop of properties) {
|
|
208
|
+
if (prop && (prop.type === "Property" || prop.type === "ObjectProperty")) {
|
|
209
|
+
const key = "key" in prop ? prop.key : null;
|
|
210
|
+
const value = "value" in prop ? prop.value : null;
|
|
211
|
+
if (key && key.type === "Identifier" && key.name === "externals" && value && value.type === "ArrayExpression") {
|
|
212
|
+
foundExternals = true;
|
|
213
|
+
additionsDebug("Found externals property in webpack.config.ts");
|
|
214
|
+
if (addI18nextToExternalsArray(value)) {
|
|
215
|
+
hasChanges = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return this.traverse(path);
|
|
222
|
+
},
|
|
223
|
+
visitProperty(path) {
|
|
224
|
+
const { node } = path;
|
|
225
|
+
if (node.key && node.key.type === "Identifier" && node.key.name === "externals" && node.value && node.value.type === "ArrayExpression") {
|
|
226
|
+
if (!foundExternals) {
|
|
227
|
+
foundExternals = true;
|
|
228
|
+
additionsDebug("Found externals property in webpack.config.ts (via visitProperty)");
|
|
229
|
+
}
|
|
230
|
+
if (addI18nextToExternalsArray(node.value)) {
|
|
231
|
+
hasChanges = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return this.traverse(path);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
if (!foundExternals) {
|
|
238
|
+
additionsDebug("No externals property found in webpack.config.ts");
|
|
239
|
+
}
|
|
240
|
+
if (hasChanges) {
|
|
241
|
+
const output = recast.print(ast, {
|
|
242
|
+
tabWidth: 2,
|
|
243
|
+
trailingComma: true,
|
|
244
|
+
lineTerminator: "\n"
|
|
245
|
+
});
|
|
246
|
+
context.updateFile(webpackConfigPath, output.code);
|
|
247
|
+
additionsDebug(`Updated ${webpackConfigPath} with i18next external`);
|
|
248
|
+
} else if (foundExternals) {
|
|
249
|
+
additionsDebug("i18next already present in externals, no changes needed");
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
additionsDebug(`Error updating ${webpackConfigPath}:`, error);
|
|
254
|
+
additionsDebug(`Error details: ${error instanceof Error ? error.message : String(error)}`);
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
additionsDebug(`File ${webpackConfigPath} exists but content is empty`);
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
additionsDebug(`File ${webpackConfigPath} does not exist`);
|
|
261
|
+
}
|
|
262
|
+
additionsDebug("No externals configuration found, skipping i18next external check");
|
|
263
|
+
} catch (error) {
|
|
264
|
+
additionsDebug(
|
|
265
|
+
`Unexpected error in ensureI18nextExternal: ${error instanceof Error ? error.message : String(error)}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
112
269
|
|
|
113
|
-
export { createI18nextConfig, updateDockerCompose, updatePluginJson };
|
|
270
|
+
export { createI18nextConfig, ensureI18nextExternal, updateDockerCompose, updatePluginJson };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as v from 'valibot';
|
|
2
2
|
import { additionsDebug } from '../../../utils.js';
|
|
3
|
-
import { updateDockerCompose, updatePluginJson, createI18nextConfig } from './config-updates.js';
|
|
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);
|
|
@@ -43,6 +44,20 @@ function i18nAddition(context, options) {
|
|
|
43
44
|
}
|
|
44
45
|
addI18nextCli(context);
|
|
45
46
|
createI18nextConfig(context);
|
|
47
|
+
try {
|
|
48
|
+
ensureI18nextExternal(context);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
additionsDebug(`Error ensuring i18next external: ${error instanceof Error ? error.message : String(error)}`);
|
|
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");
|
|
46
61
|
return context;
|
|
47
62
|
}
|
|
48
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.
|
|
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": "
|
|
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
|
|
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
|
|
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
|
|
73
|
+
## Running Multiple Times
|
|
61
74
|
|
|
62
|
-
This addition
|
|
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
|
-
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
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. **
|
|
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
|
+
});
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { coerce, gte } from 'semver';
|
|
2
2
|
import { parseDocument, stringify } from 'yaml';
|
|
3
|
+
import * as recast from 'recast';
|
|
4
|
+
import * as typeScriptParser from 'recast/parsers/typescript.js';
|
|
3
5
|
|
|
4
6
|
import type { Context } from '../../../context.js';
|
|
5
7
|
import { additionsDebug } from '../../../utils.js';
|
|
6
8
|
|
|
9
|
+
const { builders } = recast.types;
|
|
10
|
+
|
|
7
11
|
export function updateDockerCompose(context: Context): void {
|
|
8
12
|
if (!context.doesFileExist('docker-compose.yaml')) {
|
|
9
13
|
additionsDebug('docker-compose.yaml not found, skipping');
|
|
@@ -137,3 +141,221 @@ export default defineConfig({
|
|
|
137
141
|
additionsDebug('Error creating i18next.config.ts:', error);
|
|
138
142
|
}
|
|
139
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Adds 'i18next' to an externals array if it's not already present
|
|
147
|
+
* @returns true if changes were made, false otherwise
|
|
148
|
+
*/
|
|
149
|
+
function addI18nextToExternalsArray(externalsArray: recast.types.namedTypes.ArrayExpression): boolean {
|
|
150
|
+
// Check if 'i18next' is already in the array
|
|
151
|
+
const hasI18next = externalsArray.elements.some((element) => {
|
|
152
|
+
if (
|
|
153
|
+
element &&
|
|
154
|
+
(element.type === 'Literal' || element.type === 'StringLiteral') &&
|
|
155
|
+
typeof element.value === 'string'
|
|
156
|
+
) {
|
|
157
|
+
return element.value === 'i18next';
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (hasI18next) {
|
|
163
|
+
additionsDebug("'i18next' already in externals array");
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Find the position after 'rxjs' to insert 'i18next'
|
|
168
|
+
let insertIndex = -1;
|
|
169
|
+
for (let i = 0; i < externalsArray.elements.length; i++) {
|
|
170
|
+
const element = externalsArray.elements[i];
|
|
171
|
+
if (element && (element.type === 'Literal' || element.type === 'StringLiteral') && element.value === 'rxjs') {
|
|
172
|
+
insertIndex = i + 1;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// If 'rxjs' not found, append to the end (before the function at the end)
|
|
178
|
+
if (insertIndex === -1) {
|
|
179
|
+
// Find the last non-function element
|
|
180
|
+
for (let i = externalsArray.elements.length - 1; i >= 0; i--) {
|
|
181
|
+
const element = externalsArray.elements[i];
|
|
182
|
+
if (element && element.type !== 'FunctionExpression' && element.type !== 'ArrowFunctionExpression') {
|
|
183
|
+
insertIndex = i + 1;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// If still not found, append at the end
|
|
188
|
+
if (insertIndex === -1) {
|
|
189
|
+
insertIndex = externalsArray.elements.length;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Insert 'i18next' at the found position
|
|
194
|
+
externalsArray.elements.splice(insertIndex, 0, builders.literal('i18next'));
|
|
195
|
+
additionsDebug(`Added 'i18next' to externals array at position ${insertIndex}`);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function ensureI18nextExternal(context: Context): void {
|
|
200
|
+
try {
|
|
201
|
+
additionsDebug('Checking for externals configuration...');
|
|
202
|
+
|
|
203
|
+
// Try new structure first: .config/bundler/externals.ts
|
|
204
|
+
const externalsPath = '.config/bundler/externals.ts';
|
|
205
|
+
if (context.doesFileExist(externalsPath)) {
|
|
206
|
+
additionsDebug(`Found ${externalsPath}, checking for i18next...`);
|
|
207
|
+
const externalsContent = context.getFile(externalsPath);
|
|
208
|
+
if (externalsContent) {
|
|
209
|
+
try {
|
|
210
|
+
const ast = recast.parse(externalsContent, {
|
|
211
|
+
parser: typeScriptParser,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
let hasChanges = false;
|
|
215
|
+
|
|
216
|
+
// Find the externals array
|
|
217
|
+
recast.visit(ast, {
|
|
218
|
+
visitVariableDeclarator(path) {
|
|
219
|
+
const { node } = path;
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
node.id.type === 'Identifier' &&
|
|
223
|
+
node.id.name === 'externals' &&
|
|
224
|
+
node.init &&
|
|
225
|
+
node.init.type === 'ArrayExpression'
|
|
226
|
+
) {
|
|
227
|
+
additionsDebug('Found externals array in externals.ts');
|
|
228
|
+
if (addI18nextToExternalsArray(node.init)) {
|
|
229
|
+
hasChanges = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return this.traverse(path);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Only update the file if we made changes
|
|
238
|
+
if (hasChanges) {
|
|
239
|
+
const output = recast.print(ast, {
|
|
240
|
+
tabWidth: 2,
|
|
241
|
+
trailingComma: true,
|
|
242
|
+
lineTerminator: '\n',
|
|
243
|
+
});
|
|
244
|
+
context.updateFile(externalsPath, output.code);
|
|
245
|
+
additionsDebug(`Updated ${externalsPath} with i18next external`);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
additionsDebug(`Error updating ${externalsPath}:`, error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Fall back to legacy structure: .config/webpack/webpack.config.ts with inline externals
|
|
255
|
+
const webpackConfigPath = '.config/webpack/webpack.config.ts';
|
|
256
|
+
additionsDebug(`Checking for ${webpackConfigPath}...`);
|
|
257
|
+
if (context.doesFileExist(webpackConfigPath)) {
|
|
258
|
+
additionsDebug(`Found ${webpackConfigPath}, checking for inline externals...`);
|
|
259
|
+
const webpackContent = context.getFile(webpackConfigPath);
|
|
260
|
+
if (webpackContent) {
|
|
261
|
+
try {
|
|
262
|
+
const ast = recast.parse(webpackContent, {
|
|
263
|
+
parser: typeScriptParser,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
let hasChanges = false;
|
|
267
|
+
let foundExternals = false;
|
|
268
|
+
|
|
269
|
+
// Find the externals property in the Configuration object
|
|
270
|
+
// It can be in baseConfig or any variable with an object initializer
|
|
271
|
+
recast.visit(ast, {
|
|
272
|
+
visitObjectExpression(path) {
|
|
273
|
+
const { node } = path;
|
|
274
|
+
const properties = node.properties;
|
|
275
|
+
|
|
276
|
+
if (properties) {
|
|
277
|
+
for (const prop of properties) {
|
|
278
|
+
// Handle both Property and ObjectProperty types
|
|
279
|
+
if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) {
|
|
280
|
+
const key = 'key' in prop ? prop.key : null;
|
|
281
|
+
const value = 'value' in prop ? prop.value : null;
|
|
282
|
+
|
|
283
|
+
if (
|
|
284
|
+
key &&
|
|
285
|
+
key.type === 'Identifier' &&
|
|
286
|
+
key.name === 'externals' &&
|
|
287
|
+
value &&
|
|
288
|
+
value.type === 'ArrayExpression'
|
|
289
|
+
) {
|
|
290
|
+
foundExternals = true;
|
|
291
|
+
additionsDebug('Found externals property in webpack.config.ts');
|
|
292
|
+
if (addI18nextToExternalsArray(value)) {
|
|
293
|
+
hasChanges = true;
|
|
294
|
+
}
|
|
295
|
+
// Don't break, continue to check all object expressions
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return this.traverse(path);
|
|
302
|
+
},
|
|
303
|
+
visitProperty(path) {
|
|
304
|
+
const { node } = path;
|
|
305
|
+
|
|
306
|
+
// Also check properties directly (fallback)
|
|
307
|
+
if (
|
|
308
|
+
node.key &&
|
|
309
|
+
node.key.type === 'Identifier' &&
|
|
310
|
+
node.key.name === 'externals' &&
|
|
311
|
+
node.value &&
|
|
312
|
+
node.value.type === 'ArrayExpression'
|
|
313
|
+
) {
|
|
314
|
+
if (!foundExternals) {
|
|
315
|
+
foundExternals = true;
|
|
316
|
+
additionsDebug('Found externals property in webpack.config.ts (via visitProperty)');
|
|
317
|
+
}
|
|
318
|
+
if (addI18nextToExternalsArray(node.value)) {
|
|
319
|
+
hasChanges = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return this.traverse(path);
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
if (!foundExternals) {
|
|
328
|
+
additionsDebug('No externals property found in webpack.config.ts');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Only update the file if we made changes
|
|
332
|
+
if (hasChanges) {
|
|
333
|
+
const output = recast.print(ast, {
|
|
334
|
+
tabWidth: 2,
|
|
335
|
+
trailingComma: true,
|
|
336
|
+
lineTerminator: '\n',
|
|
337
|
+
});
|
|
338
|
+
context.updateFile(webpackConfigPath, output.code);
|
|
339
|
+
additionsDebug(`Updated ${webpackConfigPath} with i18next external`);
|
|
340
|
+
} else if (foundExternals) {
|
|
341
|
+
additionsDebug('i18next already present in externals, no changes needed');
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
additionsDebug(`Error updating ${webpackConfigPath}:`, error);
|
|
346
|
+
additionsDebug(`Error details: ${error instanceof Error ? error.message : String(error)}`);
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
additionsDebug(`File ${webpackConfigPath} exists but content is empty`);
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
additionsDebug(`File ${webpackConfigPath} does not exist`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
additionsDebug('No externals configuration found, skipping i18next external check');
|
|
356
|
+
} catch (error) {
|
|
357
|
+
additionsDebug(
|
|
358
|
+
`Unexpected error in ensureI18nextExternal: ${error instanceof Error ? error.message : String(error)}`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -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
|
});
|
|
@@ -2,10 +2,10 @@ import * as v from 'valibot';
|
|
|
2
2
|
|
|
3
3
|
import type { Context } from '../../../context.js';
|
|
4
4
|
import { additionsDebug } from '../../../utils.js';
|
|
5
|
-
import { updateDockerCompose, updatePluginJson, createI18nextConfig } from './config-updates.js';
|
|
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);
|
|
@@ -77,5 +80,23 @@ export default function i18nAddition(context: Context, options: I18nOptions): Co
|
|
|
77
80
|
// 10. Create i18next.config.ts
|
|
78
81
|
createI18nextConfig(context);
|
|
79
82
|
|
|
83
|
+
// 11. Ensure i18next is in externals array
|
|
84
|
+
try {
|
|
85
|
+
ensureI18nextExternal(context);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
additionsDebug(`Error ensuring i18next external: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
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
|
+
|
|
80
101
|
return context;
|
|
81
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) {
|