From 85325a82e56dbe560c39a4f62ef3cc08c62672c1 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 17 Oct 2025 09:48:24 +0200 Subject: [PATCH 01/23] add core infrastructure for 'add' command - Create additions registry for managing optional feature additions - Implement additions manager with runAddition, executeAddition - Add utilities that reuse migration infrastructure (Context, formatFiles, etc.) - Support for version-agnostic, idempotent additions - Designed to be extensible for future additions beyond i18n Fri Oct 17 09:48:24 2025 +0200 add core infrastructure for 'add' command implement 'add' command with pre-flight checks implement 'add i18n' script with full automation packages/create-plugin/src/additions/additions.ts packages/create-plugin/src/additions/manager.ts packages/create-plugin/src/additions/utils.ts --- .../create-plugin/src/additions/additions.ts | 19 +++++ .../create-plugin/src/additions/manager.ts | 77 +++++++++++++++++++ packages/create-plugin/src/additions/utils.ts | 39 ++++++++++ 3 files changed, 135 insertions(+) create mode 100644 packages/create-plugin/src/additions/additions.ts create mode 100644 packages/create-plugin/src/additions/manager.ts create mode 100644 packages/create-plugin/src/additions/utils.ts diff --git a/packages/create-plugin/src/additions/additions.ts b/packages/create-plugin/src/additions/additions.ts new file mode 100644 index 0000000000..e9aafbc8e9 --- /dev/null +++ b/packages/create-plugin/src/additions/additions.ts @@ -0,0 +1,19 @@ +export type AdditionMeta = { + name: string; + description: string; + scriptPath: string; +}; + +type Additions = { + additions: Record; +}; + +export default { + additions: { + i18n: { + name: 'i18n', + description: 'Add internationalization (i18n) support to your plugin', + scriptPath: './scripts/add-i18n.js', + }, + }, +} as Additions; diff --git a/packages/create-plugin/src/additions/manager.ts b/packages/create-plugin/src/additions/manager.ts new file mode 100644 index 0000000000..0182b2b6b4 --- /dev/null +++ b/packages/create-plugin/src/additions/manager.ts @@ -0,0 +1,77 @@ +import { additionsDebug, flushChanges, formatFiles, installNPMDependencies, printChanges } from './utils.js'; +import defaultAdditions, { AdditionMeta } from './additions.js'; + +import { Context } from '../migrations/context.js'; +import { gitCommitNoVerify } from '../utils/utils.git.js'; +import { output } from '../utils/utils.console.js'; + +export type AdditionFn = (context: Context, options?: AdditionOptions) => Context | Promise; + +export type AdditionOptions = Record; + +type RunAdditionOptions = { + commitChanges?: boolean; +}; + +export function getAvailableAdditions( + additions: Record = defaultAdditions.additions +): Record { + return additions; +} + +export function getAdditionByName( + name: string, + additions: Record = defaultAdditions.additions +): AdditionMeta | undefined { + return additions[name]; +} + +export async function runAddition( + addition: AdditionMeta, + additionOptions: AdditionOptions = {}, + runOptions: RunAdditionOptions = {} +): Promise { + const basePath = process.cwd(); + + output.log({ + title: `Running addition: ${addition.name}`, + body: [addition.description], + }); + + try { + const context = new Context(basePath); + const updatedContext = await executeAddition(addition, context, additionOptions); + const shouldCommit = runOptions.commitChanges && updatedContext.hasChanges(); + + additionsDebug(`context for "${addition.name} (${addition.scriptPath})":`); + additionsDebug('%O', updatedContext.listChanges()); + + await formatFiles(updatedContext); + flushChanges(updatedContext); + printChanges(updatedContext, addition.name, addition); + + installNPMDependencies(updatedContext); + + if (shouldCommit) { + await gitCommitNoVerify(`chore: add ${addition.name} support via create-plugin`); + } + + output.success({ + title: `Successfully added ${addition.name} to your plugin.`, + }); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Error running addition "${addition.name} (${addition.scriptPath})": ${error.message}`); + } + throw error; + } +} + +export async function executeAddition( + addition: AdditionMeta, + context: Context, + options: AdditionOptions = {} +): Promise { + const module: { default: AdditionFn } = await import(addition.scriptPath); + return module.default(context, options); +} diff --git a/packages/create-plugin/src/additions/utils.ts b/packages/create-plugin/src/additions/utils.ts new file mode 100644 index 0000000000..dca4afd081 --- /dev/null +++ b/packages/create-plugin/src/additions/utils.ts @@ -0,0 +1,39 @@ +export { + formatFiles, + installNPMDependencies, + flushChanges, + addDependenciesToPackageJson, +} from '../migrations/utils.js'; + +import type { AdditionMeta } from './additions.js'; +import { Context } from '../migrations/context.js'; +import chalk from 'chalk'; +// Re-export debug with additions namespace +import { debug } from '../utils/utils.cli.js'; +import { output } from '../utils/utils.console.js'; + +export const additionsDebug = debug.extend('additions'); + +export function printChanges(context: Context, key: string, addition: AdditionMeta) { + const changes = context.listChanges(); + const lines = []; + + for (const [filePath, { changeType }] of Object.entries(changes)) { + if (changeType === 'add') { + lines.push(`${chalk.green('ADD')} ${filePath}`); + } else if (changeType === 'update') { + lines.push(`${chalk.yellow('UPDATE')} ${filePath}`); + } else if (changeType === 'delete') { + lines.push(`${chalk.red('DELETE')} ${filePath}`); + } + } + + output.addHorizontalLine('gray'); + output.logSingleLine(`${key} (${addition.description})`); + + if (lines.length === 0) { + output.logSingleLine('No changes were made'); + } else { + output.log({ title: 'Changes:', withPrefix: false, body: output.bulletList(lines) }); + } +} From ad64883b32915aab23ced08599f3339e8577b49e Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Nov 2025 11:11:56 +0100 Subject: [PATCH 02/23] wip - add i18n script --- .../src/codemods/additions/additions.ts | 5 + .../codemods/additions/scripts/i18n.test.ts | 770 ++++++++++++++++++ .../src/codemods/additions/scripts/i18n.ts | 561 +++++++++++++ 3 files changed, 1336 insertions(+) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n.test.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n.ts diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index 07a2a0352c..9158569b47 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -6,4 +6,9 @@ export default [ description: 'Adds an example addition to the plugin', scriptPath: import.meta.resolve('./scripts/example-addition.js'), }, + { + name: 'i18n', + description: 'Adds internationalization (i18n) support to the plugin', + scriptPath: import.meta.resolve('./scripts/i18n.js'), + }, ] satisfies Codemod[]; diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n.test.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n.test.ts new file mode 100644 index 0000000000..d3327efa28 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n.test.ts @@ -0,0 +1,770 @@ +import { describe, expect, it } from 'vitest'; +import * as v from 'valibot'; + +import { Context } from '../../context.js'; +import i18nAddition, { schema } from './i18n.js'; + +describe('i18n addition', () => { + describe('schema validation', () => { + it('should require locales to be provided', () => { + const result = v.safeParse(schema, {}); + expect(result.success).toBe(false); + if (!result.success) { + const errorMessage = result.issues[0].message; + expect(errorMessage).toContain('comma-separated list'); + expect(errorMessage).toContain('en-US,es-ES,sv-SE'); + } + }); + + it('should validate locale format', () => { + const result = v.safeParse(schema, { locales: ['invalid'] }); + expect(result.success).toBe(false); + if (!result.success) { + const errorMessage = result.issues[0].message; + expect(errorMessage).toContain('xx-XX'); + expect(errorMessage).toContain('en-US'); + expect(errorMessage).toContain('sv-SE'); + } + }); + + it('should accept valid locales as array', () => { + const result = v.safeParse(schema, { locales: ['en-US', 'es-ES', 'sv-SE'] }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.locales).toEqual(['en-US', 'es-ES', 'sv-SE']); + } + }); + + it('should parse comma-separated string into array (CLI format)', () => { + const result = v.safeParse(schema, { locales: 'en-US,es-ES,sv-SE' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.locales).toEqual(['en-US', 'es-ES', 'sv-SE']); + } + }); + + it('should trim whitespace from comma-separated values', () => { + const result = v.safeParse(schema, { locales: 'en-US, es-ES , sv-SE' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.locales).toEqual(['en-US', 'es-ES', 'sv-SE']); + } + }); + }); + + it('should add i18n support with en-US locale', () => { + const context = new Context('/virtual'); + + // Set up a minimal plugin structure with Grafana 11.0.0 (needs backward compatibility) + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=11.0.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + // Check plugin.json was updated + const pluginJson = JSON.parse(result.getFile('src/plugin.json') || '{}'); + expect(pluginJson.languages).toEqual(['en-US']); + // Should stay at 11.0.0 for backward compatibility + expect(pluginJson.dependencies.grafanaDependency).toBe('>=11.0.0'); + + // Check locale file was created + expect(result.doesFileExist('src/locales/en-US/test-plugin.json')).toBe(true); + const localeContent = result.getFile('src/locales/en-US/test-plugin.json'); + const localeData = JSON.parse(localeContent || '{}'); + expect(localeData).toEqual({}); // Should be an empty object + + // Check package.json was updated with dependencies + const packageJson = JSON.parse(result.getFile('package.json') || '{}'); + expect(packageJson.dependencies['@grafana/i18n']).toBe('12.2.2'); + expect(packageJson.dependencies['semver']).toBe('^7.6.0'); + expect(packageJson.devDependencies['@types/semver']).toBe('^7.5.0'); + expect(packageJson.devDependencies['i18next-cli']).toBeDefined(); + expect(packageJson.scripts['i18n-extract']).toBe('i18next-cli extract --sync-primary'); + + // Check docker-compose.yaml was updated with feature toggle + const dockerCompose = result.getFile('docker-compose.yaml'); + expect(dockerCompose).toContain('localizationForPlugins'); + + // Check module.ts was updated with backward compatibility code + const moduleTs = result.getFile('src/module.ts'); + expect(moduleTs).toContain('initPluginTranslations'); + expect(moduleTs).toContain('semver'); + expect(moduleTs).toContain('loadResources'); + + // Check loadResources.ts was created for backward compatibility + expect(result.doesFileExist('src/loadResources.ts')).toBe(true); + const loadResources = result.getFile('src/loadResources.ts'); + expect(loadResources).toContain('ResourceLoader'); + + // Check i18next.config.ts was created + expect(result.doesFileExist('i18next.config.ts')).toBe(true); + const i18nextConfig = result.getFile('i18next.config.ts'); + expect(i18nextConfig).toContain('defineConfig'); + expect(i18nextConfig).toContain('pluginJson.id'); + }); + + it('should add i18n support with multiple locales', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=11.0.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US', 'es-ES', 'sv-SE'] }); + + // Check plugin.json has all locales + const pluginJson = JSON.parse(result.getFile('src/plugin.json') || '{}'); + expect(pluginJson.languages).toEqual(['en-US', 'es-ES', 'sv-SE']); + + // Check all locale files were created + expect(result.doesFileExist('src/locales/en-US/test-plugin.json')).toBe(true); + expect(result.doesFileExist('src/locales/es-ES/test-plugin.json')).toBe(true); + expect(result.doesFileExist('src/locales/sv-SE/test-plugin.json')).toBe(true); + }); + + it('should handle adding additional locales when i18n is already configured', () => { + const context = new Context('/virtual'); + + // Set up a plugin with i18n already configured + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + languages: ['en-US'], // Already configured + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + GF_FEATURE_TOGGLES_ENABLE: localizationForPlugins` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nimport { initPluginTranslations } from "@grafana/i18n";\nimport pluginJson from "plugin.json";\nawait initPluginTranslations(pluginJson.id);\nexport const plugin = new PanelPlugin();' + ); + context.addFile('src/locales/en-US/test-plugin.json', JSON.stringify({ existing: 'translation' })); + + const result = i18nAddition(context, { locales: ['en-US', 'es-ES'] }); + + // Should keep existing en-US locale file unchanged + const enUSContent = result.getFile('src/locales/en-US/test-plugin.json'); + expect(JSON.parse(enUSContent || '{}')).toEqual({ existing: 'translation' }); + + // Should create the new es-ES locale file + expect(result.doesFileExist('src/locales/es-ES/test-plugin.json')).toBe(true); + + // Should update plugin.json with both locales (defensive update) + const pluginJson = JSON.parse(result.getFile('src/plugin.json') || '{}'); + expect(pluginJson.languages).toEqual(['en-US', 'es-ES']); + + // Should not duplicate existing configurations + const moduleTs = result.getFile('src/module.ts'); + const initCallCount = (moduleTs?.match(/initPluginTranslations/g) || []).length; + expect(initCallCount).toBe(2); // 1 import + 1 call, not duplicated + }); + + it('should handle existing feature toggles in docker-compose.yaml (Grafana >= 12.1.0)', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + GF_FEATURE_TOGGLES_ENABLE: someOtherFeature` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + const dockerCompose = result.getFile('docker-compose.yaml'); + expect(dockerCompose).toContain('someOtherFeature,localizationForPlugins'); + }); + + it('should work with module.tsx instead of module.ts', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=11.0.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.tsx', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + const moduleTsx = result.getFile('src/module.tsx'); + expect(moduleTsx).toContain('@grafana/i18n'); + }); + + it('should not update grafanaDependency if it is already >= 12.1.0', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=13.0.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + const pluginJson = JSON.parse(result.getFile('src/plugin.json') || '{}'); + expect(pluginJson.dependencies.grafanaDependency).toBe('>=13.0.0'); + }); + + it('should handle plugins without existing scripts in package.json', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=11.0.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {} })); // No scripts field + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + const packageJson = JSON.parse(result.getFile('package.json') || '{}'); + expect(packageJson.scripts['i18n-extract']).toBe('i18next-cli extract --sync-primary'); + }); + + it('should not add ESLint config if already present', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nimport grafanaI18nPlugin from "@grafana/i18n/eslint-plugin";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + // The ESLint config should remain unchanged + const eslintConfig = result.getFile('eslint.config.mjs'); + expect(eslintConfig).toContain('@grafana/i18n/eslint-plugin'); + // Should not have duplicate imports or configs + const importCount = (eslintConfig?.match(/@grafana\/i18n\/eslint-plugin/g) || []).length; + expect(importCount).toBe(1); + }); + + it('should not create locale files if they already exist', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + // Pre-existing locale file with custom content + const customTranslations = JSON.stringify({ custom: { key: 'value' } }, null, 2); + context.addFile('src/locales/en-US/test-plugin.json', customTranslations); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + // The existing locale file should remain unchanged + const localeContent = result.getFile('src/locales/en-US/test-plugin.json'); + expect(localeContent).toBe(customTranslations); + }); + + it('should not add i18n initialization if already present', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + const moduleWithI18n = `import { PanelPlugin } from "@grafana/data"; +import { initPluginTranslations } from "@grafana/i18n"; +import pluginJson from "plugin.json"; + +await initPluginTranslations(pluginJson.id); + +export const plugin = new PanelPlugin();`; + context.addFile('src/module.ts', moduleWithI18n); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + // The module file should remain unchanged (no duplicate imports/calls) + const moduleTs = result.getFile('src/module.ts'); + const initCallCount = (moduleTs?.match(/initPluginTranslations/g) || []).length; + expect(initCallCount).toBe(2); // 1 import + 1 call + }); + + it('should not create i18next.config.ts if it already exists', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + const customI18nextConfig = 'export default { custom: true };'; + context.addFile('i18next.config.ts', customI18nextConfig); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + // The existing i18next.config.ts should remain unchanged + const i18nextConfig = result.getFile('i18next.config.ts'); + expect(i18nextConfig).toBe(customI18nextConfig); + }); + + it('should not create loadResources.ts if it already exists', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=11.0.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + const customLoadResources = 'export const loadResources = () => {};'; + context.addFile('src/loadResources.ts', customLoadResources); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + // The existing loadResources.ts should remain unchanged + const loadResources = result.getFile('src/loadResources.ts'); + expect(loadResources).toBe(customLoadResources); + }); + + it('should not add i18n-extract script if it already exists', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile( + 'package.json', + JSON.stringify({ + dependencies: {}, + devDependencies: {}, + scripts: { + 'i18n-extract': 'custom extract command', + }, + }) + ); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + // The existing i18n-extract script should remain unchanged + const packageJson = JSON.parse(result.getFile('package.json') || '{}'); + expect(packageJson.scripts['i18n-extract']).toBe('custom extract command'); + }); + + it('should add feature toggle to docker-compose for Grafana >= 12.1.0', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + const dockerCompose = result.getFile('docker-compose.yaml'); + expect(dockerCompose).toContain('GF_FEATURE_TOGGLES_ENABLE: localizationForPlugins'); + + // Should not add backward compatibility dependencies + const packageJson = JSON.parse(result.getFile('package.json') || '{}'); + expect(packageJson.dependencies['semver']).toBeUndefined(); + expect(packageJson.devDependencies['@types/semver']).toBeUndefined(); + + // Should not create loadResources.ts + expect(result.doesFileExist('src/loadResources.ts')).toBe(false); + + // Module should not have semver imports + const moduleTs = result.getFile('src/module.ts'); + expect(moduleTs).not.toContain('semver'); + expect(moduleTs).not.toContain('loadResources'); + }); + + it('should not duplicate feature toggle if already present', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + GF_FEATURE_TOGGLES_ENABLE: localizationForPlugins` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + const dockerCompose = result.getFile('docker-compose.yaml'); + // Should only have one instance of localizationForPlugins + const toggleCount = (dockerCompose?.match(/localizationForPlugins/g) || []).length; + expect(toggleCount).toBe(1); + }); + + it('should add correct ESLint config with proper rules and options', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + context.addFile( + 'docker-compose.yaml', + `services: + grafana: + environment: + FOO: bar` + ); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + context.addFile( + 'src/module.ts', + 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' + ); + + const result = i18nAddition(context, { locales: ['en-US'] }); + + const eslintConfig = result.getFile('eslint.config.mjs'); + + // Check correct import (recast uses double quotes) + expect(eslintConfig).toContain('import grafanaI18nPlugin from "@grafana/i18n/eslint-plugin"'); + + // Check plugin registration + expect(eslintConfig).toContain('"@grafana/i18n": grafanaI18nPlugin'); + + // Check rules are present + expect(eslintConfig).toContain('"@grafana/i18n/no-untranslated-strings"'); + expect(eslintConfig).toContain('"@grafana/i18n/no-translation-top-level"'); + + // Check rule configuration + expect(eslintConfig).toContain('"error"'); + expect(eslintConfig).toContain('calleesToIgnore'); + expect(eslintConfig).toContain('"^css$"'); + expect(eslintConfig).toContain('"use[A-Z].*"'); + + // Check config name + expect(eslintConfig).toContain('name: "grafana/i18n-rules"'); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n.ts new file mode 100644 index 0000000000..1b41c4f854 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n.ts @@ -0,0 +1,561 @@ +import * as recast from 'recast'; +import * as babelParser from 'recast/parsers/babel-ts.js'; +import { coerce, gte } from 'semver'; +import { parseDocument, stringify } from 'yaml'; +import * as v from 'valibot'; + +import type { Context } from '../../context.js'; +import { addDependenciesToPackageJson, additionsDebug } from '../../utils.js'; + +const { builders } = recast.types; + +/** + * I18n addition schema using Valibot + * Adds internationalization support to a plugin + */ +export const schema = v.object( + { + locales: v.pipe( + v.union([v.string(), v.array(v.string())]), + v.transform((input) => { + // Handle both string (from CLI) and array (from tests) + return typeof input === 'string' ? input.split(',').map((s) => s.trim()) : input; + }), + v.array( + v.pipe( + v.string(), + v.regex(/^[a-z]{2}-[A-Z]{2}$/, 'Locale must be in format xx-XX (e.g., en-US, es-ES, sv-SE)') + ), + 'Please provide a comma-separated list of all supported locales, e.g., "en-US,es-ES,sv-SE"' + ), + v.minLength(1, 'Please provide a comma-separated list of all supported locales, e.g., "en-US,es-ES,sv-SE"') + ), + }, + 'Please provide a comma-separated list of all supported locales, e.g., "en-US,es-ES,sv-SE"' +); + +type I18nOptions = v.InferOutput; + +export default function i18nAddition(context: Context, options: I18nOptions): Context { + const { locales } = options; + + additionsDebug('Adding i18n support with locales:', locales); + + // Determine if we need backward compatibility (Grafana < 12.1.0) + const needsBackwardCompatibility = checkNeedsBackwardCompatibility(context); + additionsDebug('Needs backward compatibility:', needsBackwardCompatibility); + + // 1. Update docker-compose.yaml with feature toggle + updateDockerCompose(context); + + // 2. Update plugin.json with languages and grafanaDependency + updatePluginJson(context, locales, needsBackwardCompatibility); + + // 3. Create locale folders and files with example translations + createLocaleFiles(context, locales); + + // 4. Add @grafana/i18n dependency + addI18nDependency(context); + + // 5. Add semver dependency for backward compatibility + if (needsBackwardCompatibility) { + addSemverDependency(context); + } + + // 6. Update eslint.config.mjs if needed + updateEslintConfig(context); + + // 7. Add i18n initialization to module file + addI18nInitialization(context, needsBackwardCompatibility); + + // 8. Create loadResources.ts for backward compatibility + if (needsBackwardCompatibility) { + createLoadResourcesFile(context); + } + + // 9. Add i18next-cli as dev dependency and add script + addI18nextCli(context); + + // 10. Create i18next.config.ts + createI18nextConfig(context); + + return context; +} + +function checkNeedsBackwardCompatibility(context: Context): boolean { + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + return false; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + const currentGrafanaDep = pluginJson.dependencies?.grafanaDependency || '>=11.0.0'; + const minVersion = coerce('12.1.0'); + const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, '')); + + // If current version is less than 12.1.0, we need backward compatibility + if (currentVersion && minVersion && gte(currentVersion, minVersion)) { + return false; // Already >= 12.1.0, no backward compat needed + } + return true; // < 12.1.0, needs backward compat + } catch (error) { + additionsDebug('Error checking backward compatibility:', error); + return true; // Default to backward compat on error + } +} + +function updateDockerCompose(context: Context): void { + if (!context.doesFileExist('docker-compose.yaml')) { + additionsDebug('docker-compose.yaml not found, skipping'); + return; + } + + const composeContent = context.getFile('docker-compose.yaml'); + if (!composeContent) { + return; + } + + try { + const composeDoc = parseDocument(composeContent); + const currentEnv = composeDoc.getIn(['services', 'grafana', 'environment']); + + if (!currentEnv) { + additionsDebug('No environment section found in docker-compose.yaml, skipping'); + return; + } + + // Check if the feature toggle is already set + if (typeof currentEnv === 'object') { + const envMap = currentEnv as any; + const toggleValue = envMap.get('GF_FEATURE_TOGGLES_ENABLE'); + + if (toggleValue) { + const toggleStr = toggleValue.toString(); + if (toggleStr.includes('localizationForPlugins')) { + additionsDebug('localizationForPlugins already in GF_FEATURE_TOGGLES_ENABLE'); + return; + } + // Append to existing feature toggles + composeDoc.setIn( + ['services', 'grafana', 'environment', 'GF_FEATURE_TOGGLES_ENABLE'], + `${toggleStr},localizationForPlugins` + ); + } else { + // Set new feature toggle + composeDoc.setIn(['services', 'grafana', 'environment', 'GF_FEATURE_TOGGLES_ENABLE'], 'localizationForPlugins'); + } + + context.updateFile('docker-compose.yaml', stringify(composeDoc)); + additionsDebug('Updated docker-compose.yaml with localizationForPlugins feature toggle'); + } + } catch (error) { + additionsDebug('Error updating docker-compose.yaml:', error); + } +} + +function updatePluginJson(context: Context, locales: string[], needsBackwardCompatibility: boolean): void { + if (!context.doesFileExist('src/plugin.json')) { + additionsDebug('src/plugin.json not found, skipping'); + return; + } + + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + return; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + + // Merge locales with existing languages (defensive: avoid duplicates) + const existingLanguages = Array.isArray(pluginJson.languages) ? pluginJson.languages : []; + const mergedLanguages = [...new Set([...existingLanguages, ...locales])]; + pluginJson.languages = mergedLanguages; + + // Update grafanaDependency based on backward compatibility needs + if (!pluginJson.dependencies) { + pluginJson.dependencies = {}; + } + + const currentGrafanaDep = pluginJson.dependencies.grafanaDependency || '>=11.0.0'; + const targetVersion = needsBackwardCompatibility ? '11.0.0' : '12.1.0'; + const minVersion = coerce(targetVersion); + const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, '')); + + if (!currentVersion || !minVersion || !gte(currentVersion, minVersion)) { + pluginJson.dependencies.grafanaDependency = `>=${targetVersion}`; + additionsDebug(`Updated grafanaDependency to >=${targetVersion}`); + } + + context.updateFile('src/plugin.json', JSON.stringify(pluginJson, null, 2)); + additionsDebug('Updated src/plugin.json with languages:', locales); + } catch (error) { + additionsDebug('Error updating src/plugin.json:', error); + } +} + +function createLocaleFiles(context: Context, locales: string[]): void { + // Get plugin ID from plugin.json + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + additionsDebug('Cannot create locale files without plugin.json'); + return; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + const pluginId = pluginJson.id; + + if (!pluginId) { + additionsDebug('No plugin ID found in plugin.json'); + return; + } + + // Create locale files for each locale (defensive: only if not already present) + for (const locale of locales) { + const localePath = `src/locales/${locale}/${pluginId}.json`; + + if (!context.doesFileExist(localePath)) { + context.addFile(localePath, JSON.stringify({}, null, 2)); + additionsDebug(`Created ${localePath}`); + } else { + additionsDebug(`${localePath} already exists, skipping`); + } + } + } catch (error) { + additionsDebug('Error creating locale files:', error); + } +} + +function addI18nDependency(context: Context): void { + addDependenciesToPackageJson(context, { '@grafana/i18n': '12.2.2' }, {}); + additionsDebug('Added @grafana/i18n dependency version 12.2.2'); +} + +function addSemverDependency(context: Context): void { + // Add semver as regular dependency and @types/semver as dev dependency + addDependenciesToPackageJson(context, { semver: '^7.6.0' }, { '@types/semver': '^7.5.0' }); + additionsDebug('Added semver dependency'); +} + +function addI18nextCli(context: Context): void { + // Add i18next-cli as dev dependency + addDependenciesToPackageJson(context, {}, { 'i18next-cli': '^1.1.1' }); + + // Add i18n-extract script to package.json + const packageJsonRaw = context.getFile('package.json'); + if (!packageJsonRaw) { + return; + } + + try { + const packageJson = JSON.parse(packageJsonRaw); + + if (!packageJson.scripts) { + packageJson.scripts = {}; + } + + // Defensive: only add if not already present + if (!packageJson.scripts['i18n-extract']) { + packageJson.scripts['i18n-extract'] = 'i18next-cli extract --sync-primary'; + context.updateFile('package.json', JSON.stringify(packageJson, null, 2)); + additionsDebug('Added i18n-extract script to package.json'); + } else { + additionsDebug('i18n-extract script already exists, skipping'); + } + } catch (error) { + additionsDebug('Error adding i18n-extract script:', error); + } +} + +function updateEslintConfig(context: Context): void { + if (!context.doesFileExist('eslint.config.mjs')) { + additionsDebug('eslint.config.mjs not found, skipping'); + return; + } + + const eslintConfigRaw = context.getFile('eslint.config.mjs'); + if (!eslintConfigRaw) { + return; + } + + // Defensive: check if @grafana/i18n eslint plugin is already configured + if (eslintConfigRaw.includes('@grafana/i18n/eslint-plugin')) { + additionsDebug('ESLint i18n rule already configured'); + return; + } + + try { + const ast = recast.parse(eslintConfigRaw, { + parser: babelParser, + }); + + // Find the import section and add the plugin import + const imports = ast.program.body.filter((node: any) => node.type === 'ImportDeclaration'); + const lastImport = imports[imports.length - 1]; + + if (lastImport) { + const pluginImport = builders.importDeclaration( + [builders.importDefaultSpecifier(builders.identifier('grafanaI18nPlugin'))], + builders.literal('@grafana/i18n/eslint-plugin') + ); + + const lastImportIndex = ast.program.body.indexOf(lastImport); + ast.program.body.splice(lastImportIndex + 1, 0, pluginImport); + } + + // Find the defineConfig array and add the plugin config + recast.visit(ast, { + visitCallExpression(path: any) { + if (path.node.callee.name === 'defineConfig' && path.node.arguments[0]?.type === 'ArrayExpression') { + const configArray = path.node.arguments[0]; + + // Add the grafana i18n config object + const i18nConfig = builders.objectExpression([ + builders.property('init', builders.identifier('name'), builders.literal('grafana/i18n-rules')), + builders.property( + 'init', + builders.identifier('plugins'), + builders.objectExpression([ + builders.property('init', builders.literal('@grafana/i18n'), builders.identifier('grafanaI18nPlugin')), + ]) + ), + builders.property( + 'init', + builders.identifier('rules'), + builders.objectExpression([ + builders.property( + 'init', + builders.literal('@grafana/i18n/no-untranslated-strings'), + builders.arrayExpression([ + builders.literal('error'), + builders.objectExpression([ + builders.property( + 'init', + builders.identifier('calleesToIgnore'), + builders.arrayExpression([builders.literal('^css$'), builders.literal('use[A-Z].*')]) + ), + ]), + ]) + ), + builders.property( + 'init', + builders.literal('@grafana/i18n/no-translation-top-level'), + builders.literal('error') + ), + ]) + ), + ]); + + configArray.elements.push(i18nConfig); + } + this.traverse(path); + }, + }); + + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }).code; + + context.updateFile('eslint.config.mjs', output); + additionsDebug('Updated eslint.config.mjs with i18n linting rules'); + } catch (error) { + additionsDebug('Error updating eslint.config.mjs:', error); + } +} + +function createI18nextConfig(context: Context): void { + // Defensive: skip if already exists + if (context.doesFileExist('i18next.config.ts')) { + additionsDebug('i18next.config.ts already exists, skipping'); + return; + } + + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + additionsDebug('Cannot create i18next.config.ts without plugin.json'); + return; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + const pluginId = pluginJson.id; + + if (!pluginId) { + additionsDebug('No plugin ID found in plugin.json'); + return; + } + + const i18nextConfig = `import { defineConfig } from 'i18next-cli'; +import pluginJson from './src/plugin.json'; + +export default defineConfig({ + locales: pluginJson.languages, + extract: { + input: ['src/**/*.{tsx,ts}'], + output: 'src/locales/{{language}}/{{namespace}}.json', + defaultNS: pluginJson.id, + functions: ['t', '*.t'], + transComponents: ['Trans'], + }, +}); +`; + + context.addFile('i18next.config.ts', i18nextConfig); + additionsDebug('Created i18next.config.ts'); + } catch (error) { + additionsDebug('Error creating i18next.config.ts:', error); + } +} + +function addI18nInitialization(context: Context, needsBackwardCompatibility: boolean): void { + // Find module.ts or module.tsx + const moduleTsPath = context.doesFileExist('src/module.ts') + ? 'src/module.ts' + : context.doesFileExist('src/module.tsx') + ? 'src/module.tsx' + : null; + + if (!moduleTsPath) { + additionsDebug('No module.ts or module.tsx found, skipping i18n initialization'); + return; + } + + const moduleContent = context.getFile(moduleTsPath); + if (!moduleContent) { + return; + } + + // Defensive: check if i18n is already initialized + if (moduleContent.includes('initPluginTranslations')) { + additionsDebug('i18n already initialized in module file'); + return; + } + + try { + const ast = recast.parse(moduleContent, { + parser: babelParser, + }); + + const imports = []; + + // Add necessary imports based on backward compatibility + imports.push( + builders.importDeclaration( + [builders.importSpecifier(builders.identifier('initPluginTranslations'))], + builders.literal('^@grafana/i18n') + ) + ); + + imports.push( + builders.importDeclaration( + [builders.importDefaultSpecifier(builders.identifier('pluginJson'))], + builders.literal('plugin.json') + ) + ); + + if (needsBackwardCompatibility) { + imports.push( + builders.importDeclaration( + [builders.importSpecifier(builders.identifier('config'))], + builders.literal('@grafana/runtime') + ) + ); + imports.push( + builders.importDeclaration( + [builders.importDefaultSpecifier(builders.identifier('semver'))], + builders.literal('semver') + ) + ); + imports.push( + builders.importDeclaration( + [builders.importSpecifier(builders.identifier('loadResources'))], + builders.literal('./loadResources') + ) + ); + } + + // Add imports after the first import statement + const firstImportIndex = ast.program.body.findIndex((node: any) => node.type === 'ImportDeclaration'); + if (firstImportIndex !== -1) { + ast.program.body.splice(firstImportIndex + 1, 0, ...imports); + } else { + ast.program.body.unshift(...imports); + } + + // Add i18n initialization code + const i18nInitCode = needsBackwardCompatibility + ? `// Before Grafana version 12.1.0 the plugin is responsible for loading translation resources +// In Grafana version 12.1.0 and later Grafana is responsible for loading translation resources +const loaders = semver.lt(config?.buildInfo?.version, '12.1.0') ? [loadResources] : []; + +await initPluginTranslations(pluginJson.id, loaders);` + : `await initPluginTranslations(pluginJson.id);`; + + // Parse the initialization code and insert it at the top level (after imports) + const initAst = recast.parse(i18nInitCode, { + parser: babelParser, + }); + + // Find the last import index + const lastImportIndex = ast.program.body.findLastIndex((node: any) => node.type === 'ImportDeclaration'); + if (lastImportIndex !== -1) { + ast.program.body.splice(lastImportIndex + 1, 0, ...initAst.program.body); + } else { + ast.program.body.unshift(...initAst.program.body); + } + + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }).code; + + context.updateFile(moduleTsPath, output); + additionsDebug(`Updated ${moduleTsPath} with i18n initialization`); + } catch (error) { + additionsDebug('Error updating module file:', error); + } +} + +function createLoadResourcesFile(context: Context): void { + const loadResourcesPath = 'src/loadResources.ts'; + + // Defensive: skip if already exists + if (context.doesFileExist(loadResourcesPath)) { + additionsDebug('loadResources.ts already exists, skipping'); + return; + } + + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + additionsDebug('Cannot create loadResources.ts without plugin.json'); + return; + } + + const loadResourcesContent = `import { LANGUAGES, ResourceLoader, Resources } from '@grafana/i18n'; +import pluginJson from 'plugin.json'; + +const resources = LANGUAGES.reduce Promise<{ default: Resources }>>>((acc, lang) => { + acc[lang.code] = async () => await import(\`./locales/\${lang.code}/\${pluginJson.id}.json\`); + return acc; +}, {}); + +export const loadResources: ResourceLoader = async (resolvedLanguage: string) => { + try { + const translation = await resources[resolvedLanguage](); + return translation.default; + } catch (error) { + // This makes sure that the plugin doesn't crash when the resolved language in Grafana isn't supported by the plugin + console.error(\`The plugin '\${pluginJson.id}' doesn't support the language '\${resolvedLanguage}'\`, error); + return {}; + } +}; +`; + + context.addFile(loadResourcesPath, loadResourcesContent); + additionsDebug('Created src/loadResources.ts for backward compatibility'); +} From e9b463bdac2ab5ca0e531927235d33c2837d87ec Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Nov 2025 11:22:44 +0100 Subject: [PATCH 03/23] split script into multiple files and add parent directory --- .../src/codemods/additions/additions.ts | 2 +- .../src/codemods/additions/scripts/i18n.ts | 561 ------------------ .../additions/scripts/i18n/code-generation.ts | 156 +++++ .../additions/scripts/i18n/config-updates.ts | 139 +++++ .../{i18n.test.ts => i18n/index.test.ts} | 4 +- .../codemods/additions/scripts/i18n/index.ts | 81 +++ .../additions/scripts/i18n/tooling.ts | 146 +++++ .../codemods/additions/scripts/i18n/utils.ts | 60 ++ 8 files changed, 585 insertions(+), 564 deletions(-) delete mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts rename packages/create-plugin/src/codemods/additions/scripts/{i18n.test.ts => i18n/index.test.ts} (99%) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index 9158569b47..8ff4416224 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -9,6 +9,6 @@ export default [ { name: 'i18n', description: 'Adds internationalization (i18n) support to the plugin', - scriptPath: import.meta.resolve('./scripts/i18n.js'), + scriptPath: import.meta.resolve('./scripts/i18n/index.js'), }, ] satisfies Codemod[]; diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n.ts deleted file mode 100644 index 1b41c4f854..0000000000 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n.ts +++ /dev/null @@ -1,561 +0,0 @@ -import * as recast from 'recast'; -import * as babelParser from 'recast/parsers/babel-ts.js'; -import { coerce, gte } from 'semver'; -import { parseDocument, stringify } from 'yaml'; -import * as v from 'valibot'; - -import type { Context } from '../../context.js'; -import { addDependenciesToPackageJson, additionsDebug } from '../../utils.js'; - -const { builders } = recast.types; - -/** - * I18n addition schema using Valibot - * Adds internationalization support to a plugin - */ -export const schema = v.object( - { - locales: v.pipe( - v.union([v.string(), v.array(v.string())]), - v.transform((input) => { - // Handle both string (from CLI) and array (from tests) - return typeof input === 'string' ? input.split(',').map((s) => s.trim()) : input; - }), - v.array( - v.pipe( - v.string(), - v.regex(/^[a-z]{2}-[A-Z]{2}$/, 'Locale must be in format xx-XX (e.g., en-US, es-ES, sv-SE)') - ), - 'Please provide a comma-separated list of all supported locales, e.g., "en-US,es-ES,sv-SE"' - ), - v.minLength(1, 'Please provide a comma-separated list of all supported locales, e.g., "en-US,es-ES,sv-SE"') - ), - }, - 'Please provide a comma-separated list of all supported locales, e.g., "en-US,es-ES,sv-SE"' -); - -type I18nOptions = v.InferOutput; - -export default function i18nAddition(context: Context, options: I18nOptions): Context { - const { locales } = options; - - additionsDebug('Adding i18n support with locales:', locales); - - // Determine if we need backward compatibility (Grafana < 12.1.0) - const needsBackwardCompatibility = checkNeedsBackwardCompatibility(context); - additionsDebug('Needs backward compatibility:', needsBackwardCompatibility); - - // 1. Update docker-compose.yaml with feature toggle - updateDockerCompose(context); - - // 2. Update plugin.json with languages and grafanaDependency - updatePluginJson(context, locales, needsBackwardCompatibility); - - // 3. Create locale folders and files with example translations - createLocaleFiles(context, locales); - - // 4. Add @grafana/i18n dependency - addI18nDependency(context); - - // 5. Add semver dependency for backward compatibility - if (needsBackwardCompatibility) { - addSemverDependency(context); - } - - // 6. Update eslint.config.mjs if needed - updateEslintConfig(context); - - // 7. Add i18n initialization to module file - addI18nInitialization(context, needsBackwardCompatibility); - - // 8. Create loadResources.ts for backward compatibility - if (needsBackwardCompatibility) { - createLoadResourcesFile(context); - } - - // 9. Add i18next-cli as dev dependency and add script - addI18nextCli(context); - - // 10. Create i18next.config.ts - createI18nextConfig(context); - - return context; -} - -function checkNeedsBackwardCompatibility(context: Context): boolean { - const pluginJsonRaw = context.getFile('src/plugin.json'); - if (!pluginJsonRaw) { - return false; - } - - try { - const pluginJson = JSON.parse(pluginJsonRaw); - const currentGrafanaDep = pluginJson.dependencies?.grafanaDependency || '>=11.0.0'; - const minVersion = coerce('12.1.0'); - const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, '')); - - // If current version is less than 12.1.0, we need backward compatibility - if (currentVersion && minVersion && gte(currentVersion, minVersion)) { - return false; // Already >= 12.1.0, no backward compat needed - } - return true; // < 12.1.0, needs backward compat - } catch (error) { - additionsDebug('Error checking backward compatibility:', error); - return true; // Default to backward compat on error - } -} - -function updateDockerCompose(context: Context): void { - if (!context.doesFileExist('docker-compose.yaml')) { - additionsDebug('docker-compose.yaml not found, skipping'); - return; - } - - const composeContent = context.getFile('docker-compose.yaml'); - if (!composeContent) { - return; - } - - try { - const composeDoc = parseDocument(composeContent); - const currentEnv = composeDoc.getIn(['services', 'grafana', 'environment']); - - if (!currentEnv) { - additionsDebug('No environment section found in docker-compose.yaml, skipping'); - return; - } - - // Check if the feature toggle is already set - if (typeof currentEnv === 'object') { - const envMap = currentEnv as any; - const toggleValue = envMap.get('GF_FEATURE_TOGGLES_ENABLE'); - - if (toggleValue) { - const toggleStr = toggleValue.toString(); - if (toggleStr.includes('localizationForPlugins')) { - additionsDebug('localizationForPlugins already in GF_FEATURE_TOGGLES_ENABLE'); - return; - } - // Append to existing feature toggles - composeDoc.setIn( - ['services', 'grafana', 'environment', 'GF_FEATURE_TOGGLES_ENABLE'], - `${toggleStr},localizationForPlugins` - ); - } else { - // Set new feature toggle - composeDoc.setIn(['services', 'grafana', 'environment', 'GF_FEATURE_TOGGLES_ENABLE'], 'localizationForPlugins'); - } - - context.updateFile('docker-compose.yaml', stringify(composeDoc)); - additionsDebug('Updated docker-compose.yaml with localizationForPlugins feature toggle'); - } - } catch (error) { - additionsDebug('Error updating docker-compose.yaml:', error); - } -} - -function updatePluginJson(context: Context, locales: string[], needsBackwardCompatibility: boolean): void { - if (!context.doesFileExist('src/plugin.json')) { - additionsDebug('src/plugin.json not found, skipping'); - return; - } - - const pluginJsonRaw = context.getFile('src/plugin.json'); - if (!pluginJsonRaw) { - return; - } - - try { - const pluginJson = JSON.parse(pluginJsonRaw); - - // Merge locales with existing languages (defensive: avoid duplicates) - const existingLanguages = Array.isArray(pluginJson.languages) ? pluginJson.languages : []; - const mergedLanguages = [...new Set([...existingLanguages, ...locales])]; - pluginJson.languages = mergedLanguages; - - // Update grafanaDependency based on backward compatibility needs - if (!pluginJson.dependencies) { - pluginJson.dependencies = {}; - } - - const currentGrafanaDep = pluginJson.dependencies.grafanaDependency || '>=11.0.0'; - const targetVersion = needsBackwardCompatibility ? '11.0.0' : '12.1.0'; - const minVersion = coerce(targetVersion); - const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, '')); - - if (!currentVersion || !minVersion || !gte(currentVersion, minVersion)) { - pluginJson.dependencies.grafanaDependency = `>=${targetVersion}`; - additionsDebug(`Updated grafanaDependency to >=${targetVersion}`); - } - - context.updateFile('src/plugin.json', JSON.stringify(pluginJson, null, 2)); - additionsDebug('Updated src/plugin.json with languages:', locales); - } catch (error) { - additionsDebug('Error updating src/plugin.json:', error); - } -} - -function createLocaleFiles(context: Context, locales: string[]): void { - // Get plugin ID from plugin.json - const pluginJsonRaw = context.getFile('src/plugin.json'); - if (!pluginJsonRaw) { - additionsDebug('Cannot create locale files without plugin.json'); - return; - } - - try { - const pluginJson = JSON.parse(pluginJsonRaw); - const pluginId = pluginJson.id; - - if (!pluginId) { - additionsDebug('No plugin ID found in plugin.json'); - return; - } - - // Create locale files for each locale (defensive: only if not already present) - for (const locale of locales) { - const localePath = `src/locales/${locale}/${pluginId}.json`; - - if (!context.doesFileExist(localePath)) { - context.addFile(localePath, JSON.stringify({}, null, 2)); - additionsDebug(`Created ${localePath}`); - } else { - additionsDebug(`${localePath} already exists, skipping`); - } - } - } catch (error) { - additionsDebug('Error creating locale files:', error); - } -} - -function addI18nDependency(context: Context): void { - addDependenciesToPackageJson(context, { '@grafana/i18n': '12.2.2' }, {}); - additionsDebug('Added @grafana/i18n dependency version 12.2.2'); -} - -function addSemverDependency(context: Context): void { - // Add semver as regular dependency and @types/semver as dev dependency - addDependenciesToPackageJson(context, { semver: '^7.6.0' }, { '@types/semver': '^7.5.0' }); - additionsDebug('Added semver dependency'); -} - -function addI18nextCli(context: Context): void { - // Add i18next-cli as dev dependency - addDependenciesToPackageJson(context, {}, { 'i18next-cli': '^1.1.1' }); - - // Add i18n-extract script to package.json - const packageJsonRaw = context.getFile('package.json'); - if (!packageJsonRaw) { - return; - } - - try { - const packageJson = JSON.parse(packageJsonRaw); - - if (!packageJson.scripts) { - packageJson.scripts = {}; - } - - // Defensive: only add if not already present - if (!packageJson.scripts['i18n-extract']) { - packageJson.scripts['i18n-extract'] = 'i18next-cli extract --sync-primary'; - context.updateFile('package.json', JSON.stringify(packageJson, null, 2)); - additionsDebug('Added i18n-extract script to package.json'); - } else { - additionsDebug('i18n-extract script already exists, skipping'); - } - } catch (error) { - additionsDebug('Error adding i18n-extract script:', error); - } -} - -function updateEslintConfig(context: Context): void { - if (!context.doesFileExist('eslint.config.mjs')) { - additionsDebug('eslint.config.mjs not found, skipping'); - return; - } - - const eslintConfigRaw = context.getFile('eslint.config.mjs'); - if (!eslintConfigRaw) { - return; - } - - // Defensive: check if @grafana/i18n eslint plugin is already configured - if (eslintConfigRaw.includes('@grafana/i18n/eslint-plugin')) { - additionsDebug('ESLint i18n rule already configured'); - return; - } - - try { - const ast = recast.parse(eslintConfigRaw, { - parser: babelParser, - }); - - // Find the import section and add the plugin import - const imports = ast.program.body.filter((node: any) => node.type === 'ImportDeclaration'); - const lastImport = imports[imports.length - 1]; - - if (lastImport) { - const pluginImport = builders.importDeclaration( - [builders.importDefaultSpecifier(builders.identifier('grafanaI18nPlugin'))], - builders.literal('@grafana/i18n/eslint-plugin') - ); - - const lastImportIndex = ast.program.body.indexOf(lastImport); - ast.program.body.splice(lastImportIndex + 1, 0, pluginImport); - } - - // Find the defineConfig array and add the plugin config - recast.visit(ast, { - visitCallExpression(path: any) { - if (path.node.callee.name === 'defineConfig' && path.node.arguments[0]?.type === 'ArrayExpression') { - const configArray = path.node.arguments[0]; - - // Add the grafana i18n config object - const i18nConfig = builders.objectExpression([ - builders.property('init', builders.identifier('name'), builders.literal('grafana/i18n-rules')), - builders.property( - 'init', - builders.identifier('plugins'), - builders.objectExpression([ - builders.property('init', builders.literal('@grafana/i18n'), builders.identifier('grafanaI18nPlugin')), - ]) - ), - builders.property( - 'init', - builders.identifier('rules'), - builders.objectExpression([ - builders.property( - 'init', - builders.literal('@grafana/i18n/no-untranslated-strings'), - builders.arrayExpression([ - builders.literal('error'), - builders.objectExpression([ - builders.property( - 'init', - builders.identifier('calleesToIgnore'), - builders.arrayExpression([builders.literal('^css$'), builders.literal('use[A-Z].*')]) - ), - ]), - ]) - ), - builders.property( - 'init', - builders.literal('@grafana/i18n/no-translation-top-level'), - builders.literal('error') - ), - ]) - ), - ]); - - configArray.elements.push(i18nConfig); - } - this.traverse(path); - }, - }); - - const output = recast.print(ast, { - tabWidth: 2, - trailingComma: true, - lineTerminator: '\n', - }).code; - - context.updateFile('eslint.config.mjs', output); - additionsDebug('Updated eslint.config.mjs with i18n linting rules'); - } catch (error) { - additionsDebug('Error updating eslint.config.mjs:', error); - } -} - -function createI18nextConfig(context: Context): void { - // Defensive: skip if already exists - if (context.doesFileExist('i18next.config.ts')) { - additionsDebug('i18next.config.ts already exists, skipping'); - return; - } - - const pluginJsonRaw = context.getFile('src/plugin.json'); - if (!pluginJsonRaw) { - additionsDebug('Cannot create i18next.config.ts without plugin.json'); - return; - } - - try { - const pluginJson = JSON.parse(pluginJsonRaw); - const pluginId = pluginJson.id; - - if (!pluginId) { - additionsDebug('No plugin ID found in plugin.json'); - return; - } - - const i18nextConfig = `import { defineConfig } from 'i18next-cli'; -import pluginJson from './src/plugin.json'; - -export default defineConfig({ - locales: pluginJson.languages, - extract: { - input: ['src/**/*.{tsx,ts}'], - output: 'src/locales/{{language}}/{{namespace}}.json', - defaultNS: pluginJson.id, - functions: ['t', '*.t'], - transComponents: ['Trans'], - }, -}); -`; - - context.addFile('i18next.config.ts', i18nextConfig); - additionsDebug('Created i18next.config.ts'); - } catch (error) { - additionsDebug('Error creating i18next.config.ts:', error); - } -} - -function addI18nInitialization(context: Context, needsBackwardCompatibility: boolean): void { - // Find module.ts or module.tsx - const moduleTsPath = context.doesFileExist('src/module.ts') - ? 'src/module.ts' - : context.doesFileExist('src/module.tsx') - ? 'src/module.tsx' - : null; - - if (!moduleTsPath) { - additionsDebug('No module.ts or module.tsx found, skipping i18n initialization'); - return; - } - - const moduleContent = context.getFile(moduleTsPath); - if (!moduleContent) { - return; - } - - // Defensive: check if i18n is already initialized - if (moduleContent.includes('initPluginTranslations')) { - additionsDebug('i18n already initialized in module file'); - return; - } - - try { - const ast = recast.parse(moduleContent, { - parser: babelParser, - }); - - const imports = []; - - // Add necessary imports based on backward compatibility - imports.push( - builders.importDeclaration( - [builders.importSpecifier(builders.identifier('initPluginTranslations'))], - builders.literal('^@grafana/i18n') - ) - ); - - imports.push( - builders.importDeclaration( - [builders.importDefaultSpecifier(builders.identifier('pluginJson'))], - builders.literal('plugin.json') - ) - ); - - if (needsBackwardCompatibility) { - imports.push( - builders.importDeclaration( - [builders.importSpecifier(builders.identifier('config'))], - builders.literal('@grafana/runtime') - ) - ); - imports.push( - builders.importDeclaration( - [builders.importDefaultSpecifier(builders.identifier('semver'))], - builders.literal('semver') - ) - ); - imports.push( - builders.importDeclaration( - [builders.importSpecifier(builders.identifier('loadResources'))], - builders.literal('./loadResources') - ) - ); - } - - // Add imports after the first import statement - const firstImportIndex = ast.program.body.findIndex((node: any) => node.type === 'ImportDeclaration'); - if (firstImportIndex !== -1) { - ast.program.body.splice(firstImportIndex + 1, 0, ...imports); - } else { - ast.program.body.unshift(...imports); - } - - // Add i18n initialization code - const i18nInitCode = needsBackwardCompatibility - ? `// Before Grafana version 12.1.0 the plugin is responsible for loading translation resources -// In Grafana version 12.1.0 and later Grafana is responsible for loading translation resources -const loaders = semver.lt(config?.buildInfo?.version, '12.1.0') ? [loadResources] : []; - -await initPluginTranslations(pluginJson.id, loaders);` - : `await initPluginTranslations(pluginJson.id);`; - - // Parse the initialization code and insert it at the top level (after imports) - const initAst = recast.parse(i18nInitCode, { - parser: babelParser, - }); - - // Find the last import index - const lastImportIndex = ast.program.body.findLastIndex((node: any) => node.type === 'ImportDeclaration'); - if (lastImportIndex !== -1) { - ast.program.body.splice(lastImportIndex + 1, 0, ...initAst.program.body); - } else { - ast.program.body.unshift(...initAst.program.body); - } - - const output = recast.print(ast, { - tabWidth: 2, - trailingComma: true, - lineTerminator: '\n', - }).code; - - context.updateFile(moduleTsPath, output); - additionsDebug(`Updated ${moduleTsPath} with i18n initialization`); - } catch (error) { - additionsDebug('Error updating module file:', error); - } -} - -function createLoadResourcesFile(context: Context): void { - const loadResourcesPath = 'src/loadResources.ts'; - - // Defensive: skip if already exists - if (context.doesFileExist(loadResourcesPath)) { - additionsDebug('loadResources.ts already exists, skipping'); - return; - } - - const pluginJsonRaw = context.getFile('src/plugin.json'); - if (!pluginJsonRaw) { - additionsDebug('Cannot create loadResources.ts without plugin.json'); - return; - } - - const loadResourcesContent = `import { LANGUAGES, ResourceLoader, Resources } from '@grafana/i18n'; -import pluginJson from 'plugin.json'; - -const resources = LANGUAGES.reduce Promise<{ default: Resources }>>>((acc, lang) => { - acc[lang.code] = async () => await import(\`./locales/\${lang.code}/\${pluginJson.id}.json\`); - return acc; -}, {}); - -export const loadResources: ResourceLoader = async (resolvedLanguage: string) => { - try { - const translation = await resources[resolvedLanguage](); - return translation.default; - } catch (error) { - // This makes sure that the plugin doesn't crash when the resolved language in Grafana isn't supported by the plugin - console.error(\`The plugin '\${pluginJson.id}' doesn't support the language '\${resolvedLanguage}'\`, error); - return {}; - } -}; -`; - - context.addFile(loadResourcesPath, loadResourcesContent); - additionsDebug('Created src/loadResources.ts for backward compatibility'); -} diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts new file mode 100644 index 0000000000..7da85267fb --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts @@ -0,0 +1,156 @@ +import * as recast from 'recast'; +import * as babelParser from 'recast/parsers/babel-ts.js'; + +import type { Context } from '../../../context.js'; +import { additionsDebug } from '../../../utils.js'; + +const { builders } = recast.types; + +export function addI18nInitialization(context: Context, needsBackwardCompatibility: boolean): void { + // Find module.ts or module.tsx + const moduleTsPath = context.doesFileExist('src/module.ts') + ? 'src/module.ts' + : context.doesFileExist('src/module.tsx') + ? 'src/module.tsx' + : null; + + if (!moduleTsPath) { + additionsDebug('No module.ts or module.tsx found, skipping i18n initialization'); + return; + } + + const moduleContent = context.getFile(moduleTsPath); + if (!moduleContent) { + return; + } + + // Defensive: check if i18n is already initialized + if (moduleContent.includes('initPluginTranslations')) { + additionsDebug('i18n already initialized in module file'); + return; + } + + try { + const ast = recast.parse(moduleContent, { + parser: babelParser, + }); + + const imports = []; + + // Add necessary imports based on backward compatibility + imports.push( + builders.importDeclaration( + [builders.importSpecifier(builders.identifier('initPluginTranslations'))], + builders.literal('@grafana/i18n') + ) + ); + + imports.push( + builders.importDeclaration( + [builders.importDefaultSpecifier(builders.identifier('pluginJson'))], + builders.literal('plugin.json') + ) + ); + + if (needsBackwardCompatibility) { + imports.push( + builders.importDeclaration( + [builders.importSpecifier(builders.identifier('config'))], + builders.literal('@grafana/runtime') + ) + ); + imports.push( + builders.importDeclaration( + [builders.importDefaultSpecifier(builders.identifier('semver'))], + builders.literal('semver') + ) + ); + imports.push( + builders.importDeclaration( + [builders.importSpecifier(builders.identifier('loadResources'))], + builders.literal('./loadResources') + ) + ); + } + + // Add imports after the first import statement + const firstImportIndex = ast.program.body.findIndex((node: any) => node.type === 'ImportDeclaration'); + if (firstImportIndex !== -1) { + ast.program.body.splice(firstImportIndex + 1, 0, ...imports); + } else { + ast.program.body.unshift(...imports); + } + + // Add i18n initialization code + const i18nInitCode = needsBackwardCompatibility + ? `// Before Grafana version 12.1.0 the plugin is responsible for loading translation resources +// In Grafana version 12.1.0 and later Grafana is responsible for loading translation resources +const loaders = semver.lt(config?.buildInfo?.version, '12.1.0') ? [loadResources] : []; + +await initPluginTranslations(pluginJson.id, loaders);` + : `await initPluginTranslations(pluginJson.id);`; + + // Parse the initialization code and insert it at the top level (after imports) + const initAst = recast.parse(i18nInitCode, { + parser: babelParser, + }); + + // Find the last import index + const lastImportIndex = ast.program.body.findLastIndex((node: any) => node.type === 'ImportDeclaration'); + if (lastImportIndex !== -1) { + ast.program.body.splice(lastImportIndex + 1, 0, ...initAst.program.body); + } else { + ast.program.body.unshift(...initAst.program.body); + } + + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }).code; + + context.updateFile(moduleTsPath, output); + additionsDebug(`Updated ${moduleTsPath} with i18n initialization`); + } catch (error) { + additionsDebug('Error updating module file:', error); + } +} + +export function createLoadResourcesFile(context: Context): void { + const loadResourcesPath = 'src/loadResources.ts'; + + // Defensive: skip if already exists + if (context.doesFileExist(loadResourcesPath)) { + additionsDebug('loadResources.ts already exists, skipping'); + return; + } + + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + additionsDebug('Cannot create loadResources.ts without plugin.json'); + return; + } + + const loadResourcesContent = `import { LANGUAGES, ResourceLoader, Resources } from '@grafana/i18n'; +import pluginJson from 'plugin.json'; + +const resources = LANGUAGES.reduce Promise<{ default: Resources }>>>((acc, lang) => { + acc[lang.code] = async () => await import(\`./locales/\${lang.code}/\${pluginJson.id}.json\`); + return acc; +}, {}); + +export const loadResources: ResourceLoader = async (resolvedLanguage: string) => { + try { + const translation = await resources[resolvedLanguage](); + return translation.default; + } catch (error) { + // This makes sure that the plugin doesn't crash when the resolved language in Grafana isn't supported by the plugin + console.error(\`The plugin '\${pluginJson.id}' doesn't support the language '\${resolvedLanguage}'\`, error); + return {}; + } +}; +`; + + context.addFile(loadResourcesPath, loadResourcesContent); + additionsDebug('Created src/loadResources.ts for backward compatibility'); +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts new file mode 100644 index 0000000000..b530fcd303 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts @@ -0,0 +1,139 @@ +import { coerce, gte } from 'semver'; +import { parseDocument, stringify } from 'yaml'; + +import type { Context } from '../../../context.js'; +import { additionsDebug } from '../../../utils.js'; + +export function updateDockerCompose(context: Context): void { + if (!context.doesFileExist('docker-compose.yaml')) { + additionsDebug('docker-compose.yaml not found, skipping'); + return; + } + + const composeContent = context.getFile('docker-compose.yaml'); + if (!composeContent) { + return; + } + + try { + const composeDoc = parseDocument(composeContent); + const currentEnv = composeDoc.getIn(['services', 'grafana', 'environment']); + + if (!currentEnv) { + additionsDebug('No environment section found in docker-compose.yaml, skipping'); + return; + } + + // Check if the feature toggle is already set + if (typeof currentEnv === 'object') { + const envMap = currentEnv as any; + const toggleValue = envMap.get('GF_FEATURE_TOGGLES_ENABLE'); + + if (toggleValue) { + const toggleStr = toggleValue.toString(); + if (toggleStr.includes('localizationForPlugins')) { + additionsDebug('localizationForPlugins already in GF_FEATURE_TOGGLES_ENABLE'); + return; + } + // Append to existing feature toggles + composeDoc.setIn( + ['services', 'grafana', 'environment', 'GF_FEATURE_TOGGLES_ENABLE'], + `${toggleStr},localizationForPlugins` + ); + } else { + // Set new feature toggle + composeDoc.setIn(['services', 'grafana', 'environment', 'GF_FEATURE_TOGGLES_ENABLE'], 'localizationForPlugins'); + } + + context.updateFile('docker-compose.yaml', stringify(composeDoc)); + additionsDebug('Updated docker-compose.yaml with localizationForPlugins feature toggle'); + } + } catch (error) { + additionsDebug('Error updating docker-compose.yaml:', error); + } +} + +export function updatePluginJson(context: Context, locales: string[], needsBackwardCompatibility: boolean): void { + if (!context.doesFileExist('src/plugin.json')) { + additionsDebug('src/plugin.json not found, skipping'); + return; + } + + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + return; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + + // Merge locales with existing languages (defensive: avoid duplicates) + const existingLanguages = Array.isArray(pluginJson.languages) ? pluginJson.languages : []; + const mergedLanguages = [...new Set([...existingLanguages, ...locales])]; + pluginJson.languages = mergedLanguages; + + // Update grafanaDependency based on backward compatibility needs + if (!pluginJson.dependencies) { + pluginJson.dependencies = {}; + } + + const currentGrafanaDep = pluginJson.dependencies.grafanaDependency || '>=11.0.0'; + const targetVersion = needsBackwardCompatibility ? '11.0.0' : '12.1.0'; + const minVersion = coerce(targetVersion); + const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, '')); + + if (!currentVersion || !minVersion || !gte(currentVersion, minVersion)) { + pluginJson.dependencies.grafanaDependency = `>=${targetVersion}`; + additionsDebug(`Updated grafanaDependency to >=${targetVersion}`); + } + + context.updateFile('src/plugin.json', JSON.stringify(pluginJson, null, 2)); + additionsDebug('Updated src/plugin.json with languages:', locales); + } catch (error) { + additionsDebug('Error updating src/plugin.json:', error); + } +} + +export function createI18nextConfig(context: Context): void { + // Defensive: skip if already exists + if (context.doesFileExist('i18next.config.ts')) { + additionsDebug('i18next.config.ts already exists, skipping'); + return; + } + + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + additionsDebug('Cannot create i18next.config.ts without plugin.json'); + return; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + const pluginId = pluginJson.id; + + if (!pluginId) { + additionsDebug('No plugin ID found in plugin.json'); + return; + } + + const i18nextConfig = `import { defineConfig } from 'i18next-cli'; +import pluginJson from './src/plugin.json'; + +export default defineConfig({ + locales: pluginJson.languages, + extract: { + input: ['src/**/*.{tsx,ts}'], + output: 'src/locales/{{language}}/{{namespace}}.json', + defaultNS: pluginJson.id, + functions: ['t', '*.t'], + transComponents: ['Trans'], + }, +}); +`; + + context.addFile('i18next.config.ts', i18nextConfig); + additionsDebug('Created i18next.config.ts'); + } catch (error) { + additionsDebug('Error creating i18next.config.ts:', error); + } +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n.test.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts similarity index 99% rename from packages/create-plugin/src/codemods/additions/scripts/i18n.test.ts rename to packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts index d3327efa28..3bbd9df2af 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import * as v from 'valibot'; -import { Context } from '../../context.js'; -import i18nAddition, { schema } from './i18n.js'; +import { Context } from '../../../context.js'; +import i18nAddition, { schema } from './index.js'; describe('i18n addition', () => { describe('schema validation', () => { diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts new file mode 100644 index 0000000000..ffb9f04eee --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts @@ -0,0 +1,81 @@ +import * as v from 'valibot'; + +import type { Context } from '../../../context.js'; +import { additionsDebug } from '../../../utils.js'; +import { updateDockerCompose, updatePluginJson, createI18nextConfig } from './config-updates.js'; +import { addI18nInitialization, createLoadResourcesFile } from './code-generation.js'; +import { updateEslintConfig, addI18nDependency, addSemverDependency, addI18nextCli } from './tooling.js'; +import { checkNeedsBackwardCompatibility, createLocaleFiles } from './utils.js'; + +/** + * I18n addition schema using Valibot + * Adds internationalization support to a plugin + */ +export const schema = v.object( + { + locales: v.pipe( + v.union([v.string(), v.array(v.string())]), + v.transform((input) => { + // Handle both string (from CLI) and array (from tests) + return typeof input === 'string' ? input.split(',').map((s) => s.trim()) : input; + }), + v.array( + v.pipe( + v.string(), + v.regex(/^[a-z]{2}-[A-Z]{2}$/, 'Locale must be in format xx-XX (e.g., en-US, es-ES, sv-SE)') + ), + 'Please provide a comma-separated list of all supported locales, e.g., "en-US,es-ES,sv-SE"' + ), + v.minLength(1, 'Please provide a comma-separated list of all supported locales, e.g., "en-US,es-ES,sv-SE"') + ), + }, + 'Please provide a comma-separated list of all supported locales, e.g., "en-US,es-ES,sv-SE"' +); + +type I18nOptions = v.InferOutput; + +export default function i18nAddition(context: Context, options: I18nOptions): Context { + const { locales } = options; + + additionsDebug('Adding i18n support with locales:', locales); + + // Determine if we need backward compatibility (Grafana < 12.1.0) + const needsBackwardCompatibility = checkNeedsBackwardCompatibility(context); + additionsDebug('Needs backward compatibility:', needsBackwardCompatibility); + + // 1. Update docker-compose.yaml with feature toggle + updateDockerCompose(context); + + // 2. Update plugin.json with languages and grafanaDependency + updatePluginJson(context, locales, needsBackwardCompatibility); + + // 3. Create locale folders and files + createLocaleFiles(context, locales); + + // 4. Add @grafana/i18n dependency + addI18nDependency(context); + + // 5. Add semver dependency for backward compatibility + if (needsBackwardCompatibility) { + addSemverDependency(context); + } + + // 6. Update eslint.config.mjs if needed + updateEslintConfig(context); + + // 7. Add i18n initialization to module file + addI18nInitialization(context, needsBackwardCompatibility); + + // 8. Create loadResources.ts for backward compatibility + if (needsBackwardCompatibility) { + createLoadResourcesFile(context); + } + + // 9. Add i18next-cli as dev dependency and add script + addI18nextCli(context); + + // 10. Create i18next.config.ts + createI18nextConfig(context); + + return context; +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts new file mode 100644 index 0000000000..4c74e2b92f --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts @@ -0,0 +1,146 @@ +import * as recast from 'recast'; +import * as babelParser from 'recast/parsers/babel-ts.js'; + +import type { Context } from '../../../context.js'; +import { addDependenciesToPackageJson, additionsDebug } from '../../../utils.js'; + +const { builders } = recast.types; + +export function addI18nDependency(context: Context): void { + addDependenciesToPackageJson(context, { '@grafana/i18n': '12.2.2' }, {}); + additionsDebug('Added @grafana/i18n dependency version 12.2.2'); +} + +export function addSemverDependency(context: Context): void { + // Add semver as regular dependency and @types/semver as dev dependency + addDependenciesToPackageJson(context, { semver: '^7.6.0' }, { '@types/semver': '^7.5.0' }); + additionsDebug('Added semver dependency'); +} + +export function addI18nextCli(context: Context): void { + // Add i18next-cli as dev dependency + addDependenciesToPackageJson(context, {}, { 'i18next-cli': '^1.1.1' }); + + // Add i18n-extract script to package.json + const packageJsonRaw = context.getFile('package.json'); + if (!packageJsonRaw) { + return; + } + + try { + const packageJson = JSON.parse(packageJsonRaw); + + if (!packageJson.scripts) { + packageJson.scripts = {}; + } + + // Defensive: only add if not already present + if (!packageJson.scripts['i18n-extract']) { + packageJson.scripts['i18n-extract'] = 'i18next-cli extract --sync-primary'; + context.updateFile('package.json', JSON.stringify(packageJson, null, 2)); + additionsDebug('Added i18n-extract script to package.json'); + } else { + additionsDebug('i18n-extract script already exists, skipping'); + } + } catch (error) { + additionsDebug('Error adding i18n-extract script:', error); + } +} + +export function updateEslintConfig(context: Context): void { + if (!context.doesFileExist('eslint.config.mjs')) { + additionsDebug('eslint.config.mjs not found, skipping'); + return; + } + + const eslintConfigRaw = context.getFile('eslint.config.mjs'); + if (!eslintConfigRaw) { + return; + } + + // Defensive: check if @grafana/i18n eslint plugin is already configured + if (eslintConfigRaw.includes('@grafana/i18n/eslint-plugin')) { + additionsDebug('ESLint i18n rule already configured'); + return; + } + + try { + const ast = recast.parse(eslintConfigRaw, { + parser: babelParser, + }); + + // Find the import section and add the plugin import + const imports = ast.program.body.filter((node: any) => node.type === 'ImportDeclaration'); + const lastImport = imports[imports.length - 1]; + + if (lastImport) { + const pluginImport = builders.importDeclaration( + [builders.importDefaultSpecifier(builders.identifier('grafanaI18nPlugin'))], + builders.literal('@grafana/i18n/eslint-plugin') + ); + + const lastImportIndex = ast.program.body.indexOf(lastImport); + ast.program.body.splice(lastImportIndex + 1, 0, pluginImport); + } + + // Find the defineConfig array and add the plugin config + recast.visit(ast, { + visitCallExpression(path: any) { + if (path.node.callee.name === 'defineConfig' && path.node.arguments[0]?.type === 'ArrayExpression') { + const configArray = path.node.arguments[0]; + + // Add the grafana i18n config object + const i18nConfig = builders.objectExpression([ + builders.property('init', builders.identifier('name'), builders.literal('grafana/i18n-rules')), + builders.property( + 'init', + builders.identifier('plugins'), + builders.objectExpression([ + builders.property('init', builders.literal('@grafana/i18n'), builders.identifier('grafanaI18nPlugin')), + ]) + ), + builders.property( + 'init', + builders.identifier('rules'), + builders.objectExpression([ + builders.property( + 'init', + builders.literal('@grafana/i18n/no-untranslated-strings'), + builders.arrayExpression([ + builders.literal('error'), + builders.objectExpression([ + builders.property( + 'init', + builders.identifier('calleesToIgnore'), + builders.arrayExpression([builders.literal('^css$'), builders.literal('use[A-Z].*')]) + ), + ]), + ]) + ), + builders.property( + 'init', + builders.literal('@grafana/i18n/no-translation-top-level'), + builders.literal('error') + ), + ]) + ), + ]); + + configArray.elements.push(i18nConfig); + } + this.traverse(path); + }, + }); + + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }).code; + + context.updateFile('eslint.config.mjs', output); + additionsDebug('Updated eslint.config.mjs with i18n linting rules'); + } catch (error) { + additionsDebug('Error updating eslint.config.mjs:', error); + } +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts new file mode 100644 index 0000000000..e3ebcad1a6 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts @@ -0,0 +1,60 @@ +import { coerce, gte } from 'semver'; + +import type { Context } from '../../../context.js'; +import { additionsDebug } from '../../../utils.js'; + +export function checkNeedsBackwardCompatibility(context: Context): boolean { + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + return false; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + const currentGrafanaDep = pluginJson.dependencies?.grafanaDependency || '>=11.0.0'; + const minVersion = coerce('12.1.0'); + const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, '')); + + // If current version is less than 12.1.0, we need backward compatibility + if (currentVersion && minVersion && gte(currentVersion, minVersion)) { + return false; // Already >= 12.1.0, no backward compat needed + } + return true; // < 12.1.0, needs backward compat + } catch (error) { + additionsDebug('Error checking backward compatibility:', error); + return true; // Default to backward compat on error + } +} + +export function createLocaleFiles(context: Context, locales: string[]): void { + // Get plugin ID from plugin.json + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + additionsDebug('Cannot create locale files without plugin.json'); + return; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + const pluginId = pluginJson.id; + + if (!pluginId) { + additionsDebug('No plugin ID found in plugin.json'); + return; + } + + // Create locale files for each locale (defensive: only if not already present) + for (const locale of locales) { + const localePath = `src/locales/${locale}/${pluginId}.json`; + + if (!context.doesFileExist(localePath)) { + context.addFile(localePath, JSON.stringify({}, null, 2)); + additionsDebug(`Created ${localePath}`); + } else { + additionsDebug(`${localePath} already exists, skipping`); + } + } + } catch (error) { + additionsDebug('Error creating locale files:', error); + } +} From 7fc88ea043bccd81309a7ef293e00ade618ec8df Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Nov 2025 11:22:53 +0100 Subject: [PATCH 04/23] update glob --- rollup.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rollup.config.ts b/rollup.config.ts index 9a2fd089a2..5a0967cae0 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -39,7 +39,7 @@ if (pkg.name === '@grafana/create-plugin') { ignore: ['**/*.test.ts'], absolute: true, }; - const codeMods = glob.sync('{migrations,additions}/scripts/*.ts', codeModsGlobOptions).map((m) => m.toString()); + const codeMods = glob.sync('{migrations,additions}/scripts/**/*.ts', codeModsGlobOptions).map((m) => m.toString()); input.push(...codeMods); external.push('prettier'); From a1722f7156649bdf42c1ff9babe2a4634bfcb106 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Nov 2025 11:26:30 +0100 Subject: [PATCH 05/23] add docs --- .../codemods/additions/scripts/i18n/README.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n/README.md diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md b/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md new file mode 100644 index 0000000000..a083442064 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md @@ -0,0 +1,157 @@ +# I18n Addition + +Adds internationalization (i18n) support to a Grafana plugin. + +## Usage + +```bash +npx @grafana/create-plugin add i18n --locales +``` + +## Required Flags + +### `--locales` + +A comma-separated list of locale codes to support in your plugin. + +**Format:** Locale codes must follow the `xx-XX` pattern (e.g., `en-US`, `es-ES`, `sv-SE`) + +**Example:** + +```bash +npx @grafana/create-plugin add i18n --locales en-US,es-ES,sv-SE +``` + +## What This Addition Does + +This addition configures your plugin for internationalization by: + +1. **Updating `docker-compose.yaml`** - Adds the `localizationForPlugins` feature toggle to your local Grafana instance +2. **Updating `src/plugin.json`** - Adds the `languages` array and updates `grafanaDependency` +3. **Creating locale files** - Creates empty JSON files for each locale at `src/locales/{locale}/{pluginId}.json` +4. **Adding dependencies** - Installs `@grafana/i18n` and optionally `semver` (for backward compatibility) +5. **Updating ESLint config** - Adds i18n linting rules to catch untranslated strings +6. **Initializing i18n in module.ts** - Adds `initPluginTranslations()` call to your plugin's entry point +7. **Creating support files**: + - `i18next.config.ts` - Configuration for extracting translations + - `src/loadResources.ts` - (Only for Grafana < 12.1.0) Custom resource loader +8. **Adding npm scripts** - Adds `i18n-extract` script to extract translations from your code + +## Backward Compatibility + +The addition automatically detects your plugin's `grafanaDependency` version: + +### Grafana >= 12.1.0 (Modern) + +- Sets `grafanaDependency` to `>=12.1.0` +- Grafana handles loading translations automatically +- Simple initialization: `await initPluginTranslations(pluginJson.id)` +- No `loadResources.ts` file needed +- No `semver` dependency needed + +### Grafana 11.0.0 - 12.0.x (Backward Compatible) + +- Keeps or sets `grafanaDependency` to `>=11.0.0` +- Plugin handles loading translations +- Creates `src/loadResources.ts` for custom resource loading +- Adds runtime version check using `semver` +- Initialization with loaders: `await initPluginTranslations(pluginJson.id, loaders)` + +## Running Multiple Times (Idempotent) + +This addition is **defensive** and can be run multiple times safely. Each operation checks if it's already been done: + +### Adding New Locales + +You can run the command again with additional locales to add them: + +```bash +# First run +npx @grafana/create-plugin add i18n --locales en-US + +# Later, add more locales +npx @grafana/create-plugin add i18n --locales en-US,es-ES,sv-SE +``` + +The addition will: + +- ✅ Merge new locales into `plugin.json` without duplicates +- ✅ Create only the new locale files (won't overwrite existing ones) +- ✅ Skip updating files that already have i18n configured + +### What Won't Be Duplicated + +- **Locale files**: Existing locale JSON files are never overwritten (preserves your translations) +- **Dependencies**: Won't re-add dependencies that already exist +- **ESLint config**: Won't duplicate the i18n plugin import or rules +- **Module initialization**: Won't add `initPluginTranslations` if it's already present +- **Support files**: Won't overwrite `i18next.config.ts` or `loadResources.ts` if they exist +- **npm scripts**: Won't overwrite the `i18n-extract` script if it exists +- **Docker feature toggle**: Won't duplicate the feature toggle + +## Files Created + +``` +your-plugin/ +├── docker-compose.yaml # Modified: adds localizationForPlugins toggle +├── src/ +│ ├── plugin.json # Modified: adds languages array +│ ├── module.ts # Modified: adds i18n initialization +│ ├── loadResources.ts # Created: (Grafana 11.x only) resource loader +│ └── locales/ +│ ├── en-US/ +│ │ └── your-plugin-id.json # Created: empty translation file +│ ├── es-ES/ +│ │ └── your-plugin-id.json # Created: empty translation file +│ └── sv-SE/ +│ └── your-plugin-id.json # Created: empty translation file +├── i18next.config.ts # Created: extraction config +├── eslint.config.mjs # Modified: adds i18n linting rules +└── package.json # Modified: adds dependencies and scripts +``` + +## Dependencies Added + +**Always:** + +- `@grafana/i18n` (v12.2.2) - i18n utilities and types +- `i18next-cli` (dev) - Translation extraction tool + +**For Grafana 11.x only:** + +- `semver` - Runtime version checking +- `@types/semver` (dev) - TypeScript types for semver + +## Next Steps + +After running this addition: + +1. **Extract translations**: Run `npm run i18n-extract` to scan your code for translatable strings +2. **Add translations**: Fill in your locale JSON files with translated strings +3. **Use in code**: Import and use the translation functions: + + ```typescript + import { t, Trans } from '@grafana/i18n'; + + // Use t() for simple strings + const title = t('components.myComponent.title', 'Default Title'); + + // Use Trans for JSX + + This is a description + + ``` + +## Debug Output + +Enable debug logging to see what the addition is doing: + +```bash +DEBUG=create-plugin:additions npx @grafana/create-plugin add i18n --locales en-US,es-ES +``` + +## References + +- [Grafana i18n Documentation](https://grafana.com/developers/plugin-tools/how-to-guides/plugin-internationalization) +- [Grafana 11.x i18n Documentation](https://grafana.com/developers/plugin-tools/how-to-guides/plugin-internationalization-grafana-11) +- [Available Languages](https://github.com/grafana/grafana/blob/main/packages/grafana-i18n/src/constants.ts) From 237e5f1a70a1ef5ac7083a480fa9f4eb165ecb7b Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Nov 2025 11:41:35 +0100 Subject: [PATCH 06/23] delete legacy files --- .../create-plugin/src/additions/additions.ts | 19 ----- .../create-plugin/src/additions/manager.ts | 77 ------------------- packages/create-plugin/src/additions/utils.ts | 39 ---------- 3 files changed, 135 deletions(-) delete mode 100644 packages/create-plugin/src/additions/additions.ts delete mode 100644 packages/create-plugin/src/additions/manager.ts delete mode 100644 packages/create-plugin/src/additions/utils.ts diff --git a/packages/create-plugin/src/additions/additions.ts b/packages/create-plugin/src/additions/additions.ts deleted file mode 100644 index e9aafbc8e9..0000000000 --- a/packages/create-plugin/src/additions/additions.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type AdditionMeta = { - name: string; - description: string; - scriptPath: string; -}; - -type Additions = { - additions: Record; -}; - -export default { - additions: { - i18n: { - name: 'i18n', - description: 'Add internationalization (i18n) support to your plugin', - scriptPath: './scripts/add-i18n.js', - }, - }, -} as Additions; diff --git a/packages/create-plugin/src/additions/manager.ts b/packages/create-plugin/src/additions/manager.ts deleted file mode 100644 index 0182b2b6b4..0000000000 --- a/packages/create-plugin/src/additions/manager.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { additionsDebug, flushChanges, formatFiles, installNPMDependencies, printChanges } from './utils.js'; -import defaultAdditions, { AdditionMeta } from './additions.js'; - -import { Context } from '../migrations/context.js'; -import { gitCommitNoVerify } from '../utils/utils.git.js'; -import { output } from '../utils/utils.console.js'; - -export type AdditionFn = (context: Context, options?: AdditionOptions) => Context | Promise; - -export type AdditionOptions = Record; - -type RunAdditionOptions = { - commitChanges?: boolean; -}; - -export function getAvailableAdditions( - additions: Record = defaultAdditions.additions -): Record { - return additions; -} - -export function getAdditionByName( - name: string, - additions: Record = defaultAdditions.additions -): AdditionMeta | undefined { - return additions[name]; -} - -export async function runAddition( - addition: AdditionMeta, - additionOptions: AdditionOptions = {}, - runOptions: RunAdditionOptions = {} -): Promise { - const basePath = process.cwd(); - - output.log({ - title: `Running addition: ${addition.name}`, - body: [addition.description], - }); - - try { - const context = new Context(basePath); - const updatedContext = await executeAddition(addition, context, additionOptions); - const shouldCommit = runOptions.commitChanges && updatedContext.hasChanges(); - - additionsDebug(`context for "${addition.name} (${addition.scriptPath})":`); - additionsDebug('%O', updatedContext.listChanges()); - - await formatFiles(updatedContext); - flushChanges(updatedContext); - printChanges(updatedContext, addition.name, addition); - - installNPMDependencies(updatedContext); - - if (shouldCommit) { - await gitCommitNoVerify(`chore: add ${addition.name} support via create-plugin`); - } - - output.success({ - title: `Successfully added ${addition.name} to your plugin.`, - }); - } catch (error) { - if (error instanceof Error) { - throw new Error(`Error running addition "${addition.name} (${addition.scriptPath})": ${error.message}`); - } - throw error; - } -} - -export async function executeAddition( - addition: AdditionMeta, - context: Context, - options: AdditionOptions = {} -): Promise { - const module: { default: AdditionFn } = await import(addition.scriptPath); - return module.default(context, options); -} diff --git a/packages/create-plugin/src/additions/utils.ts b/packages/create-plugin/src/additions/utils.ts deleted file mode 100644 index dca4afd081..0000000000 --- a/packages/create-plugin/src/additions/utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -export { - formatFiles, - installNPMDependencies, - flushChanges, - addDependenciesToPackageJson, -} from '../migrations/utils.js'; - -import type { AdditionMeta } from './additions.js'; -import { Context } from '../migrations/context.js'; -import chalk from 'chalk'; -// Re-export debug with additions namespace -import { debug } from '../utils/utils.cli.js'; -import { output } from '../utils/utils.console.js'; - -export const additionsDebug = debug.extend('additions'); - -export function printChanges(context: Context, key: string, addition: AdditionMeta) { - const changes = context.listChanges(); - const lines = []; - - for (const [filePath, { changeType }] of Object.entries(changes)) { - if (changeType === 'add') { - lines.push(`${chalk.green('ADD')} ${filePath}`); - } else if (changeType === 'update') { - lines.push(`${chalk.yellow('UPDATE')} ${filePath}`); - } else if (changeType === 'delete') { - lines.push(`${chalk.red('DELETE')} ${filePath}`); - } - } - - output.addHorizontalLine('gray'); - output.logSingleLine(`${key} (${addition.description})`); - - if (lines.length === 0) { - output.logSingleLine('No changes were made'); - } else { - output.log({ title: 'Changes:', withPrefix: false, body: output.bulletList(lines) }); - } -} From 232868a8d4d04fe50b3b71f3cdc193a8fabdde82 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Nov 2025 12:07:51 +0100 Subject: [PATCH 07/23] use semver range --- .../src/codemods/additions/scripts/i18n/tooling.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts index 4c74e2b92f..dfbece39a0 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts @@ -7,8 +7,8 @@ import { addDependenciesToPackageJson, additionsDebug } from '../../../utils.js' const { builders } = recast.types; export function addI18nDependency(context: Context): void { - addDependenciesToPackageJson(context, { '@grafana/i18n': '12.2.2' }, {}); - additionsDebug('Added @grafana/i18n dependency version 12.2.2'); + addDependenciesToPackageJson(context, { '@grafana/i18n': '^12.2.2' }, {}); + additionsDebug('Added @grafana/i18n dependency version ^12.2.2'); } export function addSemverDependency(context: Context): void { From e96c3047d157d408411f0e7a17081210bcd954c2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Nov 2025 12:13:42 +0100 Subject: [PATCH 08/23] fix broken test --- .../src/codemods/additions/scripts/i18n/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts index 3bbd9df2af..19fb0e90bd 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts @@ -100,7 +100,7 @@ describe('i18n addition', () => { // Check package.json was updated with dependencies const packageJson = JSON.parse(result.getFile('package.json') || '{}'); - expect(packageJson.dependencies['@grafana/i18n']).toBe('12.2.2'); + expect(packageJson.dependencies['@grafana/i18n']).toBe('^12.2.2'); expect(packageJson.dependencies['semver']).toBe('^7.6.0'); expect(packageJson.devDependencies['@types/semver']).toBe('^7.5.0'); expect(packageJson.devDependencies['i18next-cli']).toBeDefined(); From 62cd9885cb6f83ae96ea34813485b4a2eaf5a189 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Nov 2025 12:20:34 +0100 Subject: [PATCH 09/23] add section about additions --- packages/create-plugin/src/codemods/AGENTS.md | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/create-plugin/src/codemods/AGENTS.md b/packages/create-plugin/src/codemods/AGENTS.md index a36dafb934..14734d43a7 100644 --- a/packages/create-plugin/src/codemods/AGENTS.md +++ b/packages/create-plugin/src/codemods/AGENTS.md @@ -5,17 +5,24 @@ This guide provides specific instructions for working with migrations and additi ## Agent Behavior - Refer to current migrations and additions typescript files found in @./additions/scripts and @./migrations/scripts -- When creating a new migration add it to the exported migrations object in @./migrations/migrations.ts - Always refer to @./context.ts to know what methods are available on the context class - Always check for file existence using the @./context.ts class before attempting to do anything with it - Never write files with any 3rd party npm library. Use the context for all file operations -- Always return the context for the next migration +- Always return the context for the next migration/addition - Test thoroughly using the provided utils in @./test-utils.ts where necessary +- Never attempt to read or write files outside the current working directory + +## Migrations + +Migrations are automatically run during `create-plugin update` to keep plugins compatible with newer versions of the tooling. They are forced upon developers to ensure compatibility and are versioned based on the create-plugin version. Migrations primarily target configuration files or files that are scaffolded by create-plugin. + +### Migration Behavior + +- When creating a new migration add it to the exported migrations object in @./migrations/migrations.ts - Each migration must be idempotent and must include a test case that uses the `.toBeIdempotent` custom matcher found in @../../vitest.setup.ts - Keep migrations focused on one task -- Never attempt to read or write files outside the current working directory -## Naming Conventions +### Migration Naming Conventions - Each migration lives under @./migrations/scripts - Migration filenames follow the format: `NNN-migration-title` where migration-title is, at the most, a three word summary of what the migration does and NNN is the next number in sequence based on the current file name in @./migrations/scripts @@ -23,3 +30,16 @@ This guide provides specific instructions for working with migrations and additi - `NNN-migration-title.ts` - main migration logic - `NNN-migration-title.test.ts` - migration logic tests - Each migration should export a default function named "migrate" + +## Additions + +Additions are optional features that developers choose to add via `create-plugin add`. They are not versioned and can be run at any time to enhance a plugin with new capabilities. + +### Addition Behavior + +- Additions add new features or capabilities to a plugin (e.g., i18n support, testing frameworks, etc.) +- Each addition must be idempotent - it should be safe to run multiple times +- Always use defensive programming: check if features already exist before adding them +- Use `additionsDebug()` for logging to help with troubleshooting +- If the addition accepts user input, export a `schema` object using `valibot` for input validation +- Each addition should export a default function that takes `(context: Context, options?: T)` and returns `Context` From 3154eaa192b00fc3ebd4b3a388cd44bebfd175e8 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Nov 2025 15:48:09 +0100 Subject: [PATCH 10/23] change grafana dep --- packages/create-plugin/templates/panel/src/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-plugin/templates/panel/src/plugin.json b/packages/create-plugin/templates/panel/src/plugin.json index 24bfe95233..43027876b5 100644 --- a/packages/create-plugin/templates/panel/src/plugin.json +++ b/packages/create-plugin/templates/panel/src/plugin.json @@ -19,7 +19,7 @@ "updated": "%TODAY%" }, "dependencies": { - "grafanaDependency": ">=10.4.0", + "grafanaDependency": ">=11.5.0", "plugins": [] } } From 30dc8f545c02089dce32da281d01bcacda8828b2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 15 Dec 2025 12:44:48 +0100 Subject: [PATCH 11/23] check externals --- .../additions/scripts/i18n/config-updates.ts | 222 ++++++++++++++++++ .../codemods/additions/scripts/i18n/index.ts | 9 +- 2 files changed, 230 insertions(+), 1 deletion(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts index b530fcd303..56dcd98420 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts @@ -1,9 +1,13 @@ import { coerce, gte } from 'semver'; import { parseDocument, stringify } from 'yaml'; +import * as recast from 'recast'; +import * as typeScriptParser from 'recast/parsers/typescript.js'; import type { Context } from '../../../context.js'; import { additionsDebug } from '../../../utils.js'; +const { builders } = recast.types; + export function updateDockerCompose(context: Context): void { if (!context.doesFileExist('docker-compose.yaml')) { additionsDebug('docker-compose.yaml not found, skipping'); @@ -137,3 +141,221 @@ export default defineConfig({ additionsDebug('Error creating i18next.config.ts:', error); } } + +/** + * Adds 'i18next' to an externals array if it's not already present + * @returns true if changes were made, false otherwise + */ +function addI18nextToExternalsArray(externalsArray: recast.types.namedTypes.ArrayExpression): boolean { + // Check if 'i18next' is already in the array + const hasI18next = externalsArray.elements.some((element) => { + if ( + element && + (element.type === 'Literal' || element.type === 'StringLiteral') && + typeof element.value === 'string' + ) { + return element.value === 'i18next'; + } + return false; + }); + + if (hasI18next) { + additionsDebug("'i18next' already in externals array"); + return false; + } + + // Find the position after 'rxjs' to insert 'i18next' + let insertIndex = -1; + for (let i = 0; i < externalsArray.elements.length; i++) { + const element = externalsArray.elements[i]; + if (element && (element.type === 'Literal' || element.type === 'StringLiteral') && element.value === 'rxjs') { + insertIndex = i + 1; + break; + } + } + + // If 'rxjs' not found, append to the end (before the function at the end) + if (insertIndex === -1) { + // Find the last non-function element + for (let i = externalsArray.elements.length - 1; i >= 0; i--) { + const element = externalsArray.elements[i]; + if (element && element.type !== 'FunctionExpression' && element.type !== 'ArrowFunctionExpression') { + insertIndex = i + 1; + break; + } + } + // If still not found, append at the end + if (insertIndex === -1) { + insertIndex = externalsArray.elements.length; + } + } + + // Insert 'i18next' at the found position + externalsArray.elements.splice(insertIndex, 0, builders.literal('i18next')); + additionsDebug(`Added 'i18next' to externals array at position ${insertIndex}`); + return true; +} + +export function ensureI18nextExternal(context: Context): void { + try { + additionsDebug('Checking for externals configuration...'); + + // Try new structure first: .config/bundler/externals.ts + const externalsPath = '.config/bundler/externals.ts'; + if (context.doesFileExist(externalsPath)) { + additionsDebug(`Found ${externalsPath}, checking for i18next...`); + const externalsContent = context.getFile(externalsPath); + if (externalsContent) { + try { + const ast = recast.parse(externalsContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + + // Find the externals array + recast.visit(ast, { + visitVariableDeclarator(path) { + const { node } = path; + + if ( + node.id.type === 'Identifier' && + node.id.name === 'externals' && + node.init && + node.init.type === 'ArrayExpression' + ) { + additionsDebug('Found externals array in externals.ts'); + if (addI18nextToExternalsArray(node.init)) { + hasChanges = true; + } + } + + return this.traverse(path); + }, + }); + + // Only update the file if we made changes + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(externalsPath, output.code); + additionsDebug(`Updated ${externalsPath} with i18next external`); + } + return; + } catch (error) { + additionsDebug(`Error updating ${externalsPath}:`, error); + } + } + } + + // Fall back to legacy structure: .config/webpack/webpack.config.ts with inline externals + const webpackConfigPath = '.config/webpack/webpack.config.ts'; + additionsDebug(`Checking for ${webpackConfigPath}...`); + if (context.doesFileExist(webpackConfigPath)) { + additionsDebug(`Found ${webpackConfigPath}, checking for inline externals...`); + const webpackContent = context.getFile(webpackConfigPath); + if (webpackContent) { + try { + const ast = recast.parse(webpackContent, { + parser: typeScriptParser, + }); + + let hasChanges = false; + let foundExternals = false; + + // Find the externals property in the Configuration object + // It can be in baseConfig or any variable with an object initializer + recast.visit(ast, { + visitObjectExpression(path) { + const { node } = path; + const properties = node.properties; + + if (properties) { + for (const prop of properties) { + // Handle both Property and ObjectProperty types + if (prop && (prop.type === 'Property' || prop.type === 'ObjectProperty')) { + const key = 'key' in prop ? prop.key : null; + const value = 'value' in prop ? prop.value : null; + + if ( + key && + key.type === 'Identifier' && + key.name === 'externals' && + value && + value.type === 'ArrayExpression' + ) { + foundExternals = true; + additionsDebug('Found externals property in webpack.config.ts'); + if (addI18nextToExternalsArray(value)) { + hasChanges = true; + } + // Don't break, continue to check all object expressions + } + } + } + } + + return this.traverse(path); + }, + visitProperty(path) { + const { node } = path; + + // Also check properties directly (fallback) + if ( + node.key && + node.key.type === 'Identifier' && + node.key.name === 'externals' && + node.value && + node.value.type === 'ArrayExpression' + ) { + if (!foundExternals) { + foundExternals = true; + additionsDebug('Found externals property in webpack.config.ts (via visitProperty)'); + } + if (addI18nextToExternalsArray(node.value)) { + hasChanges = true; + } + } + + return this.traverse(path); + }, + }); + + if (!foundExternals) { + additionsDebug('No externals property found in webpack.config.ts'); + } + + // Only update the file if we made changes + if (hasChanges) { + const output = recast.print(ast, { + tabWidth: 2, + trailingComma: true, + lineTerminator: '\n', + }); + context.updateFile(webpackConfigPath, output.code); + additionsDebug(`Updated ${webpackConfigPath} with i18next external`); + } else if (foundExternals) { + additionsDebug('i18next already present in externals, no changes needed'); + } + return; + } catch (error) { + additionsDebug(`Error updating ${webpackConfigPath}:`, error); + additionsDebug(`Error details: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + additionsDebug(`File ${webpackConfigPath} exists but content is empty`); + } + } else { + additionsDebug(`File ${webpackConfigPath} does not exist`); + } + + additionsDebug('No externals configuration found, skipping i18next external check'); + } catch (error) { + additionsDebug( + `Unexpected error in ensureI18nextExternal: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts index ffb9f04eee..4511c4a3d0 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts @@ -2,7 +2,7 @@ import * as v from 'valibot'; import type { Context } from '../../../context.js'; import { additionsDebug } from '../../../utils.js'; -import { updateDockerCompose, updatePluginJson, createI18nextConfig } from './config-updates.js'; +import { updateDockerCompose, updatePluginJson, createI18nextConfig, ensureI18nextExternal } from './config-updates.js'; import { addI18nInitialization, createLoadResourcesFile } from './code-generation.js'; import { updateEslintConfig, addI18nDependency, addSemverDependency, addI18nextCli } from './tooling.js'; import { checkNeedsBackwardCompatibility, createLocaleFiles } from './utils.js'; @@ -77,5 +77,12 @@ export default function i18nAddition(context: Context, options: I18nOptions): Co // 10. Create i18next.config.ts createI18nextConfig(context); + // 11. Ensure i18next is in externals array + try { + ensureI18nextExternal(context); + } catch (error) { + additionsDebug(`Error ensuring i18next external: ${error instanceof Error ? error.message : String(error)}`); + } + return context; } From 24782672178238cf67c56895ce99ef27c4f53d6f Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 09:27:08 +0100 Subject: [PATCH 12/23] exit early in case react 17 is used --- .../codemods/additions/scripts/i18n/index.ts | 5 ++- .../codemods/additions/scripts/i18n/utils.ts | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts index 4511c4a3d0..4c365455f0 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts @@ -5,7 +5,7 @@ import { additionsDebug } from '../../../utils.js'; import { updateDockerCompose, updatePluginJson, createI18nextConfig, ensureI18nextExternal } from './config-updates.js'; import { addI18nInitialization, createLoadResourcesFile } from './code-generation.js'; import { updateEslintConfig, addI18nDependency, addSemverDependency, addI18nextCli } from './tooling.js'; -import { checkNeedsBackwardCompatibility, createLocaleFiles } from './utils.js'; +import { checkNeedsBackwardCompatibility, createLocaleFiles, checkReactVersion } from './utils.js'; /** * I18n addition schema using Valibot @@ -39,6 +39,9 @@ export default function i18nAddition(context: Context, options: I18nOptions): Co additionsDebug('Adding i18n support with locales:', locales); + // Check React version early - @grafana/i18n requires React 18+ + checkReactVersion(context); + // Determine if we need backward compatibility (Grafana < 12.1.0) const needsBackwardCompatibility = checkNeedsBackwardCompatibility(context); additionsDebug('Needs backward compatibility:', needsBackwardCompatibility); diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts index e3ebcad1a6..e110561c04 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts @@ -3,6 +3,43 @@ import { coerce, gte } from 'semver'; import type { Context } from '../../../context.js'; import { additionsDebug } from '../../../utils.js'; +/** + * Checks if React version is >= 18 + * @throws Error if React < 18 (since @grafana/i18n requires React 18+) + */ +export function checkReactVersion(context: Context): void { + const packageJsonRaw = context.getFile('package.json'); + if (!packageJsonRaw) { + return; + } + + try { + const packageJson = JSON.parse(packageJsonRaw); + const reactVersion = + packageJson.dependencies?.react || packageJson.devDependencies?.react || packageJson.peerDependencies?.react; + + if (reactVersion) { + const reactVersionStr = reactVersion.replace(/[^0-9.]/g, ''); + const reactVersionCoerced = coerce(reactVersionStr); + + if (reactVersionCoerced && !gte(reactVersionCoerced, '18.0.0')) { + throw new Error( + `@grafana/i18n requires React 18 or higher. Your plugin is using React ${reactVersion}.\n\n` + + `Please upgrade to React 18+ to use i18n support.\n` + + `Update your package.json to use "react": "^18.3.0" and "react-dom": "^18.3.0".` + ); + } + } + } catch (error) { + // If it's our version check error, re-throw it + if (error instanceof Error && error.message.includes('@grafana/i18n requires React')) { + throw error; + } + // Otherwise, just log and continue (can't determine React version) + additionsDebug('Error checking React version:', error); + } +} + export function checkNeedsBackwardCompatibility(context: Context): boolean { const pluginJsonRaw = context.getFile('src/plugin.json'); if (!pluginJsonRaw) { From 00fd6b69b7925e22fa7cbc1a3d39c64e183c6980 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 09:32:38 +0100 Subject: [PATCH 13/23] add success messages with next steps --- .../src/codemods/additions/scripts/i18n/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts index 4c365455f0..35549fa123 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts @@ -87,5 +87,16 @@ export default function i18nAddition(context: Context, options: I18nOptions): Co additionsDebug(`Error ensuring i18next external: ${error instanceof Error ? error.message : String(error)}`); } + // Success message with next steps + console.log('\n✅ i18n support has been successfully added to your plugin!\n'); + console.log('Next steps:'); + console.log('1. Follow the instructions to translate your source code:'); + console.log( + ' https://grafana.com/developers/plugin-tools/how-to-guides/plugin-internationalization-grafana-11#determine-the-text-to-translate' + ); + console.log('2. Run the i18n-extract script to scan your code for translatable strings:'); + console.log(' npm run i18n-extract (or yarn/pnpm run i18n-extract)'); + console.log('3. Fill in your locale JSON files with translated strings\n'); + return context; } From 01ccf59a8b032f22ffab0c04d83ff030b15bd67f Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 09:52:12 +0100 Subject: [PATCH 14/23] improve readme --- .../codemods/additions/scripts/i18n/README.md | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md b/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md index a083442064..f133be8dd7 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md @@ -8,6 +8,11 @@ Adds internationalization (i18n) support to a Grafana plugin. npx @grafana/create-plugin add i18n --locales ``` +## Requirements + +- **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`. +- **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. + ## Required Flags ### `--locales` @@ -24,6 +29,12 @@ npx @grafana/create-plugin add i18n --locales en-US,es-ES,sv-SE ## What This Addition Does +**Important:** This script sets up the infrastructure and configuration needed for translations. After running this script, you'll need to: + +1. Mark up your code with translation functions (`t()` and ``) +2. Run `npm run i18n-extract` to extract translatable strings +3. Fill in the locale JSON files with translated strings + This addition configures your plugin for internationalization by: 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: ## Backward Compatibility +**Note:** i18n is not supported for Grafana versions prior to 11.0.0. + The addition automatically detects your plugin's `grafanaDependency` version: -### Grafana >= 12.1.0 (Modern) +### Grafana >= 12.1.0 - Sets `grafanaDependency` to `>=12.1.0` - Grafana handles loading translations automatically @@ -49,7 +62,7 @@ The addition automatically detects your plugin's `grafanaDependency` version: - No `loadResources.ts` file needed - No `semver` dependency needed -### Grafana 11.0.0 - 12.0.x (Backward Compatible) +### Grafana 11.0.0 - 12.0.x - Keeps or sets `grafanaDependency` to `>=11.0.0` - Plugin handles loading translations @@ -57,7 +70,7 @@ The addition automatically detects your plugin's `grafanaDependency` version: - Adds runtime version check using `semver` - Initialization with loaders: `await initPluginTranslations(pluginJson.id, loaders)` -## Running Multiple Times (Idempotent) +## Running Multiple Times This addition is **defensive** and can be run multiple times safely. Each operation checks if it's already been done: @@ -79,16 +92,6 @@ The addition will: - ✅ Create only the new locale files (won't overwrite existing ones) - ✅ Skip updating files that already have i18n configured -### What Won't Be Duplicated - -- **Locale files**: Existing locale JSON files are never overwritten (preserves your translations) -- **Dependencies**: Won't re-add dependencies that already exist -- **ESLint config**: Won't duplicate the i18n plugin import or rules -- **Module initialization**: Won't add `initPluginTranslations` if it's already present -- **Support files**: Won't overwrite `i18next.config.ts` or `loadResources.ts` if they exist -- **npm scripts**: Won't overwrite the `i18n-extract` script if it exists -- **Docker feature toggle**: Won't duplicate the feature toggle - ## Files Created ``` @@ -126,9 +129,7 @@ your-plugin/ After running this addition: -1. **Extract translations**: Run `npm run i18n-extract` to scan your code for translatable strings -2. **Add translations**: Fill in your locale JSON files with translated strings -3. **Use in code**: Import and use the translation functions: +1. **Use in code**: Import and use the translation functions to mark up your code: ```typescript import { t, Trans } from '@grafana/i18n'; @@ -142,6 +143,9 @@ After running this addition: ``` +2. **Extract translations**: Run `npm run i18n-extract` to scan your code for translatable strings +3. **Add translations**: Fill in your locale JSON files with translated strings + ## Debug Output Enable debug logging to see what the addition is doing: From c124f6a54a8b5035825cddf78cfd4a3a4726bdf8 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 10:03:06 +0100 Subject: [PATCH 15/23] add unit tests --- .../scripts/i18n/config-updates.test.ts | 209 ++++++++++++++++++ .../additions/scripts/i18n/index.test.ts | 96 -------- .../additions/scripts/i18n/tooling.test.ts | 80 +++++++ .../additions/scripts/i18n/utils.test.ts | 133 +++++++++++ 4 files changed, 422 insertions(+), 96 deletions(-) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.test.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.test.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/i18n/utils.test.ts diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.test.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.test.ts new file mode 100644 index 0000000000..2f4e4145e3 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest'; + +import { Context } from '../../../context.js'; +import { ensureI18nextExternal, updatePluginJson } from './config-updates.js'; + +describe('config-updates', () => { + describe('ensureI18nextExternal', () => { + it('should add i18next to externals array in .config/bundler/externals.ts', () => { + const context = new Context('/virtual'); + + context.addFile('.config/bundler/externals.ts', `export const externals = ['react', 'react-dom'];`); + + ensureI18nextExternal(context); + + const externalsContent = context.getFile('.config/bundler/externals.ts'); + expect(externalsContent).toContain("'i18next'"); + expect(externalsContent).toContain("'react'"); + expect(externalsContent).toContain("'react-dom'"); + }); + + it('should not duplicate i18next if already in externals array', () => { + const context = new Context('/virtual'); + + const originalExternals = `export const externals = ['react', 'i18next', 'react-dom'];`; + context.addFile('.config/bundler/externals.ts', originalExternals); + + ensureI18nextExternal(context); + + const externalsContent = context.getFile('.config/bundler/externals.ts'); + const i18nextCount = (externalsContent?.match(/'i18next'/g) || []).length; + expect(i18nextCount).toBe(1); + }); + + it('should add i18next to externals in .config/webpack/webpack.config.ts (legacy)', () => { + const context = new Context('/virtual'); + + context.addFile( + '.config/webpack/webpack.config.ts', + `import { Configuration } from 'webpack'; +export const config: Configuration = { + externals: ['react', 'react-dom'], +};` + ); + + ensureI18nextExternal(context); + + const webpackConfig = context.getFile('.config/webpack/webpack.config.ts'); + expect(webpackConfig).toContain("'i18next'"); + expect(webpackConfig).toContain("'react'"); + expect(webpackConfig).toContain("'react-dom'"); + }); + + it('should handle missing externals configuration gracefully', () => { + const context = new Context('/virtual'); + // No externals.ts or webpack.config.ts + + expect(() => { + ensureI18nextExternal(context); + }).not.toThrow(); + }); + + it('should prefer .config/bundler/externals.ts over webpack.config.ts', () => { + const context = new Context('/virtual'); + + context.addFile('.config/bundler/externals.ts', `export const externals = ['react'];`); + context.addFile('.config/webpack/webpack.config.ts', `export const config = { externals: ['react-dom'] };`); + + ensureI18nextExternal(context); + + // Should update externals.ts, not webpack.config.ts + const externalsContent = context.getFile('.config/bundler/externals.ts'); + expect(externalsContent).toContain("'i18next'"); + + const webpackConfig = context.getFile('.config/webpack/webpack.config.ts'); + expect(webpackConfig).not.toContain("'i18next'"); + }); + }); + + describe('updatePluginJson', () => { + it('should auto-update grafanaDependency from < 11.0.0 to >=11.0.0', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=10.0.0', + }, + }) + ); + + updatePluginJson(context, ['en-US'], true); + + const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}'); + expect(pluginJson.dependencies.grafanaDependency).toBe('>=11.0.0'); + expect(pluginJson.languages).toEqual(['en-US']); + }); + + it('should keep grafanaDependency >= 11.0.0 unchanged when needsBackwardCompatibility is true', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=11.0.0', + }, + }) + ); + + updatePluginJson(context, ['en-US'], true); + + const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}'); + expect(pluginJson.dependencies.grafanaDependency).toBe('>=11.0.0'); + }); + + it('should update grafanaDependency to >=12.1.0 when needsBackwardCompatibility is false', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=11.0.0', + }, + }) + ); + + updatePluginJson(context, ['en-US'], false); + + const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}'); + expect(pluginJson.dependencies.grafanaDependency).toBe('>=12.1.0'); + }); + + it('should merge locales with existing languages', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + languages: ['en-US'], + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + + updatePluginJson(context, ['es-ES', 'sv-SE'], false); + + const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}'); + expect(pluginJson.languages).toEqual(['en-US', 'es-ES', 'sv-SE']); + }); + + it('should not duplicate locales', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + languages: ['en-US', 'es-ES'], + dependencies: { + grafanaDependency: '>=12.1.0', + }, + }) + ); + + updatePluginJson(context, ['en-US', 'sv-SE'], false); + + const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}'); + expect(pluginJson.languages).toEqual(['en-US', 'es-ES', 'sv-SE']); + }); + + it('should not update grafanaDependency if it is already >= target version', () => { + const context = new Context('/virtual'); + + context.addFile( + 'src/plugin.json', + JSON.stringify({ + id: 'test-plugin', + type: 'panel', + name: 'Test Plugin', + dependencies: { + grafanaDependency: '>=13.0.0', + }, + }) + ); + + updatePluginJson(context, ['en-US'], false); + + const pluginJson = JSON.parse(context.getFile('src/plugin.json') || '{}'); + expect(pluginJson.dependencies.grafanaDependency).toBe('>=13.0.0'); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts index 19fb0e90bd..7e4a2ba890 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.test.ts @@ -372,47 +372,6 @@ describe('i18n addition', () => { expect(packageJson.scripts['i18n-extract']).toBe('i18next-cli extract --sync-primary'); }); - it('should not add ESLint config if already present', () => { - const context = new Context('/virtual'); - - context.addFile( - 'src/plugin.json', - JSON.stringify({ - id: 'test-plugin', - type: 'panel', - name: 'Test Plugin', - dependencies: { - grafanaDependency: '>=12.1.0', - }, - }) - ); - context.addFile( - 'docker-compose.yaml', - `services: - grafana: - environment: - FOO: bar` - ); - context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); - context.addFile( - 'eslint.config.mjs', - 'import { defineConfig } from "eslint/config";\nimport grafanaI18nPlugin from "@grafana/i18n/eslint-plugin";\nexport default defineConfig([]);' - ); - context.addFile( - 'src/module.ts', - 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' - ); - - const result = i18nAddition(context, { locales: ['en-US'] }); - - // The ESLint config should remain unchanged - const eslintConfig = result.getFile('eslint.config.mjs'); - expect(eslintConfig).toContain('@grafana/i18n/eslint-plugin'); - // Should not have duplicate imports or configs - const importCount = (eslintConfig?.match(/@grafana\/i18n\/eslint-plugin/g) || []).length; - expect(importCount).toBe(1); - }); - it('should not create locale files if they already exist', () => { const context = new Context('/virtual'); @@ -712,59 +671,4 @@ export const plugin = new PanelPlugin();`; const toggleCount = (dockerCompose?.match(/localizationForPlugins/g) || []).length; expect(toggleCount).toBe(1); }); - - it('should add correct ESLint config with proper rules and options', () => { - const context = new Context('/virtual'); - - context.addFile( - 'src/plugin.json', - JSON.stringify({ - id: 'test-plugin', - type: 'panel', - name: 'Test Plugin', - dependencies: { - grafanaDependency: '>=12.1.0', - }, - }) - ); - context.addFile( - 'docker-compose.yaml', - `services: - grafana: - environment: - FOO: bar` - ); - context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {}, scripts: {} })); - context.addFile( - 'eslint.config.mjs', - 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' - ); - context.addFile( - 'src/module.ts', - 'import { PanelPlugin } from "@grafana/data";\nexport const plugin = new PanelPlugin();' - ); - - const result = i18nAddition(context, { locales: ['en-US'] }); - - const eslintConfig = result.getFile('eslint.config.mjs'); - - // Check correct import (recast uses double quotes) - expect(eslintConfig).toContain('import grafanaI18nPlugin from "@grafana/i18n/eslint-plugin"'); - - // Check plugin registration - expect(eslintConfig).toContain('"@grafana/i18n": grafanaI18nPlugin'); - - // Check rules are present - expect(eslintConfig).toContain('"@grafana/i18n/no-untranslated-strings"'); - expect(eslintConfig).toContain('"@grafana/i18n/no-translation-top-level"'); - - // Check rule configuration - expect(eslintConfig).toContain('"error"'); - expect(eslintConfig).toContain('calleesToIgnore'); - expect(eslintConfig).toContain('"^css$"'); - expect(eslintConfig).toContain('"use[A-Z].*"'); - - // Check config name - expect(eslintConfig).toContain('name: "grafana/i18n-rules"'); - }); }); diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.test.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.test.ts new file mode 100644 index 0000000000..1c464e84b4 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { Context } from '../../../context.js'; +import { updateEslintConfig } from './tooling.js'; + +describe('tooling', () => { + describe('updateEslintConfig', () => { + it('should add correct ESLint config with proper rules and options', () => { + const context = new Context('/virtual'); + + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nexport default defineConfig([]);' + ); + + updateEslintConfig(context); + + const eslintConfig = context.getFile('eslint.config.mjs'); + + // Check correct import (recast uses double quotes) + expect(eslintConfig).toContain('import grafanaI18nPlugin from "@grafana/i18n/eslint-plugin"'); + + // Check plugin registration + expect(eslintConfig).toContain('"@grafana/i18n": grafanaI18nPlugin'); + + // Check rules are present + expect(eslintConfig).toContain('"@grafana/i18n/no-untranslated-strings"'); + expect(eslintConfig).toContain('"@grafana/i18n/no-translation-top-level"'); + + // Check rule configuration + expect(eslintConfig).toContain('"error"'); + expect(eslintConfig).toContain('calleesToIgnore'); + expect(eslintConfig).toContain('"^css$"'); + expect(eslintConfig).toContain('"use[A-Z].*"'); + + // Check config name + expect(eslintConfig).toContain('name: "grafana/i18n-rules"'); + }); + + it('should not add ESLint config if already present', () => { + const context = new Context('/virtual'); + + context.addFile( + 'eslint.config.mjs', + 'import { defineConfig } from "eslint/config";\nimport grafanaI18nPlugin from "@grafana/i18n/eslint-plugin";\nexport default defineConfig([]);' + ); + + const originalContent = context.getFile('eslint.config.mjs'); + + updateEslintConfig(context); + + // The ESLint config should remain unchanged + const eslintConfig = context.getFile('eslint.config.mjs'); + expect(eslintConfig).toBe(originalContent); + expect(eslintConfig).toContain('@grafana/i18n/eslint-plugin'); + // Should not have duplicate imports or configs + const importCount = (eslintConfig?.match(/@grafana\/i18n\/eslint-plugin/g) || []).length; + expect(importCount).toBe(1); + }); + + it('should handle missing eslint.config.mjs gracefully', () => { + const context = new Context('/virtual'); + // No eslint.config.mjs file + + expect(() => { + updateEslintConfig(context); + }).not.toThrow(); + }); + + it('should handle empty eslint.config.mjs gracefully', () => { + const context = new Context('/virtual'); + + context.addFile('eslint.config.mjs', ''); + + expect(() => { + updateEslintConfig(context); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.test.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.test.ts new file mode 100644 index 0000000000..b00eb54819 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { Context } from '../../../context.js'; +import { checkReactVersion } from './utils.js'; + +describe('utils', () => { + describe('checkReactVersion', () => { + it('should throw error if React < 18 in dependencies', () => { + const context = new Context('/virtual'); + + context.addFile( + 'package.json', + JSON.stringify({ + dependencies: { + react: '^17.0.2', + 'react-dom': '^17.0.2', + }, + }) + ); + + expect(() => { + checkReactVersion(context); + }).toThrow('@grafana/i18n requires React 18 or higher'); + }); + + it('should throw error if React 17 in devDependencies', () => { + const context = new Context('/virtual'); + + context.addFile( + 'package.json', + JSON.stringify({ + devDependencies: { + react: '^17.0.2', + 'react-dom': '^17.0.2', + }, + }) + ); + + expect(() => { + checkReactVersion(context); + }).toThrow('@grafana/i18n requires React 18 or higher'); + }); + + it('should throw error if React 17 in peerDependencies', () => { + const context = new Context('/virtual'); + + context.addFile( + 'package.json', + JSON.stringify({ + peerDependencies: { + react: '^17.0.2', + 'react-dom': '^17.0.2', + }, + }) + ); + + expect(() => { + checkReactVersion(context); + }).toThrow('@grafana/i18n requires React 18 or higher'); + }); + + it('should continue if React >= 18', () => { + const context = new Context('/virtual'); + + context.addFile( + 'package.json', + JSON.stringify({ + dependencies: { + react: '^18.3.0', + 'react-dom': '^18.3.0', + }, + }) + ); + + expect(() => { + checkReactVersion(context); + }).not.toThrow(); + }); + + it('should continue if React version cannot be determined (no package.json)', () => { + const context = new Context('/virtual'); + // No package.json file + + expect(() => { + checkReactVersion(context); + }).not.toThrow(); + }); + + it('should continue if React version cannot be determined (no React dependency)', () => { + const context = new Context('/virtual'); + + context.addFile('package.json', JSON.stringify({})); // No React dependency + + expect(() => { + checkReactVersion(context); + }).not.toThrow(); + }); + + it('should handle version ranges correctly', () => { + const context = new Context('/virtual'); + + context.addFile( + 'package.json', + JSON.stringify({ + dependencies: { + react: '~18.1.0', + }, + }) + ); + + expect(() => { + checkReactVersion(context); + }).not.toThrow(); + }); + + it('should handle React 19', () => { + const context = new Context('/virtual'); + + context.addFile( + 'package.json', + JSON.stringify({ + dependencies: { + react: '^19.0.0', + }, + }) + ); + + expect(() => { + checkReactVersion(context); + }).not.toThrow(); + }); + }); +}); From b7f800ac9bb7f206bf21a205c80e7cc005d448b5 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 10:19:23 +0100 Subject: [PATCH 16/23] fix readme --- .../src/codemods/additions/scripts/i18n/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md b/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md index f133be8dd7..ea84f337b4 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md @@ -72,7 +72,7 @@ The addition automatically detects your plugin's `grafanaDependency` version: ## Running Multiple Times -This addition is **defensive** and can be run multiple times safely. Each operation checks if it's already been done: +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: ### Adding New Locales @@ -88,9 +88,9 @@ npx @grafana/create-plugin add i18n --locales en-US,es-ES,sv-SE The addition will: -- ✅ Merge new locales into `plugin.json` without duplicates -- ✅ Create only the new locale files (won't overwrite existing ones) -- ✅ Skip updating files that already have i18n configured +- Merge new locales into `plugin.json` without duplicates +- Create only the new locale files (won't overwrite existing ones) +- Skip updating files that already have i18n configured ## Files Created From b0981277ec981917253aadb1de73cacec98fa821 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 10:19:30 +0100 Subject: [PATCH 17/23] remove example script --- packages/create-plugin/src/codemods/additions/additions.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index 8ff4416224..4df2f5c7d1 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -1,11 +1,6 @@ import { Codemod } from '../types.js'; export default [ - { - name: 'example-addition', - description: 'Adds an example addition to the plugin', - scriptPath: import.meta.resolve('./scripts/example-addition.js'), - }, { name: 'i18n', description: 'Adds internationalization (i18n) support to the plugin', From 86a6a59b551c99495a9abdebedecb6b01ba0b3e7 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 10:19:46 +0100 Subject: [PATCH 18/23] fix broken test --- .../additions/scripts/i18n/config-updates.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.test.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.test.ts index 2f4e4145e3..abf6a2b2e3 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.test.ts @@ -13,7 +13,7 @@ describe('config-updates', () => { ensureI18nextExternal(context); const externalsContent = context.getFile('.config/bundler/externals.ts'); - expect(externalsContent).toContain("'i18next'"); + expect(externalsContent).toMatch(/["']i18next["']/); expect(externalsContent).toContain("'react'"); expect(externalsContent).toContain("'react-dom'"); }); @@ -27,7 +27,7 @@ describe('config-updates', () => { ensureI18nextExternal(context); const externalsContent = context.getFile('.config/bundler/externals.ts'); - const i18nextCount = (externalsContent?.match(/'i18next'/g) || []).length; + const i18nextCount = (externalsContent?.match(/["']i18next["']/g) || []).length; expect(i18nextCount).toBe(1); }); @@ -45,7 +45,7 @@ export const config: Configuration = { ensureI18nextExternal(context); const webpackConfig = context.getFile('.config/webpack/webpack.config.ts'); - expect(webpackConfig).toContain("'i18next'"); + expect(webpackConfig).toMatch(/["']i18next["']/); expect(webpackConfig).toContain("'react'"); expect(webpackConfig).toContain("'react-dom'"); }); @@ -69,10 +69,10 @@ export const config: Configuration = { // Should update externals.ts, not webpack.config.ts const externalsContent = context.getFile('.config/bundler/externals.ts'); - expect(externalsContent).toContain("'i18next'"); + expect(externalsContent).toMatch(/["']i18next["']/); const webpackConfig = context.getFile('.config/webpack/webpack.config.ts'); - expect(webpackConfig).not.toContain("'i18next'"); + expect(webpackConfig).not.toMatch(/["']i18next["']/); }); }); From 5e0937987c8ffd0348b2f86506532b07ab99b79c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 13:10:27 +0100 Subject: [PATCH 19/23] remove idempotent text --- packages/create-plugin/src/codemods/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-plugin/src/codemods/AGENTS.md b/packages/create-plugin/src/codemods/AGENTS.md index 14734d43a7..c8fc242bf2 100644 --- a/packages/create-plugin/src/codemods/AGENTS.md +++ b/packages/create-plugin/src/codemods/AGENTS.md @@ -38,7 +38,7 @@ Additions are optional features that developers choose to add via `create-plugin ### Addition Behavior - Additions add new features or capabilities to a plugin (e.g., i18n support, testing frameworks, etc.) -- Each addition must be idempotent - it should be safe to run multiple times +- it should be safe to run multiple times - Always use defensive programming: check if features already exist before adding them - Use `additionsDebug()` for logging to help with troubleshooting - If the addition accepts user input, export a `schema` object using `valibot` for input validation From 4d82d6edf447e420c8f5377b11b258a1326aabbc Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 13:16:10 +0100 Subject: [PATCH 20/23] simplify logic --- .../additions/scripts/i18n/config-updates.ts | 32 ++----------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts index 56dcd98420..ed1c017d62 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/config-updates.ts @@ -164,35 +164,9 @@ function addI18nextToExternalsArray(externalsArray: recast.types.namedTypes.Arra return false; } - // Find the position after 'rxjs' to insert 'i18next' - let insertIndex = -1; - for (let i = 0; i < externalsArray.elements.length; i++) { - const element = externalsArray.elements[i]; - if (element && (element.type === 'Literal' || element.type === 'StringLiteral') && element.value === 'rxjs') { - insertIndex = i + 1; - break; - } - } - - // If 'rxjs' not found, append to the end (before the function at the end) - if (insertIndex === -1) { - // Find the last non-function element - for (let i = externalsArray.elements.length - 1; i >= 0; i--) { - const element = externalsArray.elements[i]; - if (element && element.type !== 'FunctionExpression' && element.type !== 'ArrowFunctionExpression') { - insertIndex = i + 1; - break; - } - } - // If still not found, append at the end - if (insertIndex === -1) { - insertIndex = externalsArray.elements.length; - } - } - - // Insert 'i18next' at the found position - externalsArray.elements.splice(insertIndex, 0, builders.literal('i18next')); - additionsDebug(`Added 'i18next' to externals array at position ${insertIndex}`); + // Append 'i18next' to the end of the array + externalsArray.elements.push(builders.literal('i18next')); + additionsDebug("Added 'i18next' to externals array"); return true; } From 07db27b3ed6f3271f9772bf2b67a413c14481ed3 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 13:29:40 +0100 Subject: [PATCH 21/23] additiondebug --- .../src/codemods/additions/scripts/i18n/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts index 35549fa123..6f4c23fedd 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/index.ts @@ -88,15 +88,15 @@ export default function i18nAddition(context: Context, options: I18nOptions): Co } // Success message with next steps - console.log('\n✅ i18n support has been successfully added to your plugin!\n'); - console.log('Next steps:'); - console.log('1. Follow the instructions to translate your source code:'); - console.log( + additionsDebug('\n✅ i18n support has been successfully added to your plugin!\n'); + additionsDebug('Next steps:'); + additionsDebug('1. Follow the instructions to translate your source code:'); + additionsDebug( ' https://grafana.com/developers/plugin-tools/how-to-guides/plugin-internationalization-grafana-11#determine-the-text-to-translate' ); - console.log('2. Run the i18n-extract script to scan your code for translatable strings:'); - console.log(' npm run i18n-extract (or yarn/pnpm run i18n-extract)'); - console.log('3. Fill in your locale JSON files with translated strings\n'); + additionsDebug('2. Run the i18n-extract script to scan your code for translatable strings:'); + additionsDebug(' npm run i18n-extract (or yarn/pnpm run i18n-extract)'); + additionsDebug('3. Fill in your locale JSON files with translated strings\n'); return context; } From e19da0201ecead327f60620f43c6bface1e2585a Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 13:44:57 +0100 Subject: [PATCH 22/23] copilot pr feedback --- packages/create-plugin/src/codemods/AGENTS.md | 2 +- .../codemods/additions/scripts/i18n/README.md | 2 +- .../additions/scripts/i18n/code-generation.ts | 22 ++++++++++--------- .../additions/scripts/i18n/tooling.ts | 14 +++++++----- .../codemods/additions/scripts/i18n/utils.ts | 13 +++++++++-- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/packages/create-plugin/src/codemods/AGENTS.md b/packages/create-plugin/src/codemods/AGENTS.md index c8fc242bf2..1ca11b05d8 100644 --- a/packages/create-plugin/src/codemods/AGENTS.md +++ b/packages/create-plugin/src/codemods/AGENTS.md @@ -38,7 +38,7 @@ Additions are optional features that developers choose to add via `create-plugin ### Addition Behavior - Additions add new features or capabilities to a plugin (e.g., i18n support, testing frameworks, etc.) -- it should be safe to run multiple times +- It should be safe to run multiple times - Always use defensive programming: check if features already exist before adding them - Use `additionsDebug()` for logging to help with troubleshooting - If the addition accepts user input, export a `schema` object using `valibot` for input validation diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md b/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md index ea84f337b4..9f8638e6b8 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/README.md @@ -10,7 +10,7 @@ npx @grafana/create-plugin add i18n --locales ## Requirements -- **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`. +- **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` (it will not exit with an error). - **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. ## Required Flags diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts index 7da85267fb..4eecb73d34 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts @@ -73,10 +73,12 @@ export function addI18nInitialization(context: Context, needsBackwardCompatibili ); } - // Add imports after the first import statement - const firstImportIndex = ast.program.body.findIndex((node: any) => node.type === 'ImportDeclaration'); - if (firstImportIndex !== -1) { - ast.program.body.splice(firstImportIndex + 1, 0, ...imports); + // Find the last import index (use consistent approach for both imports and initialization) + const lastImportIndex = ast.program.body.findLastIndex((node: any) => node.type === 'ImportDeclaration'); + + // Add imports after the last import statement + if (lastImportIndex !== -1) { + ast.program.body.splice(lastImportIndex + 1, 0, ...imports); } else { ast.program.body.unshift(...imports); } @@ -85,7 +87,7 @@ export function addI18nInitialization(context: Context, needsBackwardCompatibili const i18nInitCode = needsBackwardCompatibility ? `// Before Grafana version 12.1.0 the plugin is responsible for loading translation resources // In Grafana version 12.1.0 and later Grafana is responsible for loading translation resources -const loaders = semver.lt(config?.buildInfo?.version, '12.1.0') ? [loadResources] : []; +const loaders = semver.lt(config?.buildInfo?.version || '0.0.0', '12.1.0') ? [loadResources] : []; await initPluginTranslations(pluginJson.id, loaders);` : `await initPluginTranslations(pluginJson.id);`; @@ -95,10 +97,10 @@ await initPluginTranslations(pluginJson.id, loaders);` parser: babelParser, }); - // Find the last import index - const lastImportIndex = ast.program.body.findLastIndex((node: any) => node.type === 'ImportDeclaration'); - if (lastImportIndex !== -1) { - ast.program.body.splice(lastImportIndex + 1, 0, ...initAst.program.body); + // Find the last import index again (after adding new imports) + const finalLastImportIndex = ast.program.body.findLastIndex((node: any) => node.type === 'ImportDeclaration'); + if (finalLastImportIndex !== -1) { + ast.program.body.splice(finalLastImportIndex + 1, 0, ...initAst.program.body); } else { ast.program.body.unshift(...initAst.program.body); } @@ -135,7 +137,7 @@ export function createLoadResourcesFile(context: Context): void { import pluginJson from 'plugin.json'; const resources = LANGUAGES.reduce Promise<{ default: Resources }>>>((acc, lang) => { - acc[lang.code] = async () => await import(\`./locales/\${lang.code}/\${pluginJson.id}.json\`); + acc[lang.code] = () => import(\`./locales/\${lang.code}/\${pluginJson.id}.json\`); return acc; }, {}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts index dfbece39a0..2b755f4d92 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/tooling.ts @@ -73,14 +73,18 @@ export function updateEslintConfig(context: Context): void { const imports = ast.program.body.filter((node: any) => node.type === 'ImportDeclaration'); const lastImport = imports[imports.length - 1]; - if (lastImport) { - const pluginImport = builders.importDeclaration( - [builders.importDefaultSpecifier(builders.identifier('grafanaI18nPlugin'))], - builders.literal('@grafana/i18n/eslint-plugin') - ); + // Always create the plugin import + const pluginImport = builders.importDeclaration( + [builders.importDefaultSpecifier(builders.identifier('grafanaI18nPlugin'))], + builders.literal('@grafana/i18n/eslint-plugin') + ); + if (lastImport) { const lastImportIndex = ast.program.body.indexOf(lastImport); ast.program.body.splice(lastImportIndex + 1, 0, pluginImport); + } else { + // No imports found, insert at the beginning + ast.program.body.unshift(pluginImport); } // Find the defineConfig array and add the plugin config diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts index e110561c04..a0b44ef5da 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/utils.ts @@ -43,12 +43,21 @@ export function checkReactVersion(context: Context): void { export function checkNeedsBackwardCompatibility(context: Context): boolean { const pluginJsonRaw = context.getFile('src/plugin.json'); if (!pluginJsonRaw) { - return false; + // Default to backward compat for safety when plugin.json is missing + return true; } try { const pluginJson = JSON.parse(pluginJsonRaw); - const currentGrafanaDep = pluginJson.dependencies?.grafanaDependency || '>=11.0.0'; + const currentGrafanaDep = pluginJson.dependencies?.grafanaDependency; + + if (!currentGrafanaDep) { + additionsDebug( + 'Warning: grafanaDependency is missing from plugin.json. Assuming backward compatibility mode is needed.' + ); + return true; + } + const minVersion = coerce('12.1.0'); const currentVersion = coerce(currentGrafanaDep.replace(/^[><=]+/, '')); From 3ba04f23a2c9ad9c14ec1b54b8c833f11da4290b Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 16 Dec 2025 13:47:31 +0100 Subject: [PATCH 23/23] revert one suggestion --- .../src/codemods/additions/scripts/i18n/code-generation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts b/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts index 4eecb73d34..818f2cea5c 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/i18n/code-generation.ts @@ -87,7 +87,7 @@ export function addI18nInitialization(context: Context, needsBackwardCompatibili const i18nInitCode = needsBackwardCompatibility ? `// Before Grafana version 12.1.0 the plugin is responsible for loading translation resources // In Grafana version 12.1.0 and later Grafana is responsible for loading translation resources -const loaders = semver.lt(config?.buildInfo?.version || '0.0.0', '12.1.0') ? [loadResources] : []; +const loaders = semver.lt(config?.buildInfo?.version, '12.1.0') ? [loadResources] : []; await initPluginTranslations(pluginJson.id, loaders);` : `await initPluginTranslations(pluginJson.id);`;