diff --git a/packages/create-plugin/README.md b/packages/create-plugin/README.md index 613ab4cd25..b1ec03d12d 100644 --- a/packages/create-plugin/README.md +++ b/packages/create-plugin/README.md @@ -122,6 +122,45 @@ For more information see our [documentation](https://grafana.com/developers/plug --- +## Add optional features to your existing plugin + +You can add optional features to your plugin using the `add` command. This allows you to enhance your plugin with additional capabilities without starting from scratch. + +### Add internationalization (i18n) support + +Add translation support to make your plugin available in multiple languages: + +```bash +# Run this command from the root of your plugin +cd ./my-plugin + +npx @grafana/create-plugin@latest add i18n +``` + +This will: + +- Update your `plugin.json` with the selected languages +- Create locale folders and translation files +- Add the necessary dependencies to `package.json` +- Configure your docker-compose.yaml with the required feature toggle +- Add i18n imports to your module file +- Set up the i18n extraction script + +The command will prompt you to select which locales you want to support. You can choose from common locales like: + +- English (US) - `en-US` +- Spanish (Spain) - `es-ES` +- French (France) - `fr-FR` +- German (Germany) - `de-DE` +- Swedish (Sweden) - `sv-SE` +- And more... + +You can also add custom locale codes during the interactive prompt. + +For more information about plugin internationalization, see our [documentation](https://grafana.com/developers/plugin-tools/how-to-guides/plugin-internationalization). + +--- + ## Contributing We are always grateful for contributions! See [CONTRIBUTING.md](../../CONTRIBUTING.md) for more information. 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/scripts/add-i18n.test.ts b/packages/create-plugin/src/additions/scripts/add-i18n.test.ts new file mode 100644 index 0000000000..160125be7d --- /dev/null +++ b/packages/create-plugin/src/additions/scripts/add-i18n.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it } from 'vitest'; + +import { Context } from '../../migrations/context.js'; +import migrate from './add-i18n.js'; + +describe('add-i18n', () => { + it('should be idempotent', async () => { + const context = new Context('/virtual'); + + // Set up a minimal plugin structure + 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:\n grafana:\n environment:\n FOO: bar'); + context.addFile('package.json', JSON.stringify({ dependencies: {}, devDependencies: {} })); + 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 migrateWithOptions = (ctx: Context) => migrate(ctx, { locales: ['en-US'] }); + await expect(migrateWithOptions).toBeIdempotent(context); + }); + + it('should add i18n support with a single locale (backward compatibility for Grafana < 12.1.0)', () => { + 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 = migrate(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 with example translations + 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).toHaveProperty('components'); + expect(localeData).toHaveProperty('config'); + + // 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 NOT updated (backward compat doesn't add feature toggle) + const dockerCompose = result.getFile('docker-compose.yaml'); + expect(dockerCompose).not.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 = migrate(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 skip if 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: + 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";\nimport { i18n } from "@grafana/i18n";\nexport const plugin = new PanelPlugin();' + ); + + // Flush the context to simulate these files existing on "disk" + const initialChanges = Object.keys(context.listChanges()).length; + + const result = migrate(context, { locales: ['es-ES'] }); + + // Should not add any NEW changes beyond the initial setup + const finalChanges = Object.keys(result.listChanges()).length; + expect(finalChanges).toBe(initialChanges); + }); + + 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 = migrate(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 = migrate(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 = migrate(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 = migrate(context, { locales: ['en-US'] }); + + const packageJson = JSON.parse(result.getFile('package.json') || '{}'); + expect(packageJson.scripts['i18n-extract']).toBe('i18next-cli extract --sync-primary'); + }); +}); diff --git a/packages/create-plugin/src/additions/scripts/add-i18n.ts b/packages/create-plugin/src/additions/scripts/add-i18n.ts new file mode 100644 index 0000000000..d031b3d80b --- /dev/null +++ b/packages/create-plugin/src/additions/scripts/add-i18n.ts @@ -0,0 +1,568 @@ +import * as recast from 'recast'; + +import { addDependenciesToPackageJson, additionsDebug } from '../utils.js'; +import { coerce, gte } from 'semver'; +import { parseDocument, stringify } from 'yaml'; + +import type { Context } from '../../migrations/context.js'; + +const { builders } = recast.types; + +export type I18nOptions = { + locales: string[]; +}; + +export default function migrate(context: Context, options: I18nOptions = { locales: ['en-US'] }): Context { + const { locales } = options; + + additionsDebug('Adding i18n support with locales:', locales); + + // Check if i18n is already configured + if (isI18nConfigured(context)) { + additionsDebug('i18n already configured, skipping'); + return context; + } + + // 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 (only if >= 12.1.0) + if (!needsBackwardCompatibility) { + 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 isI18nConfigured(context: Context): boolean { + // Check if plugin.json has languages field + if (!context.doesFileExist('src/plugin.json')) { + return false; + } + + const pluginJsonRaw = context.getFile('src/plugin.json'); + if (!pluginJsonRaw) { + return false; + } + + try { + const pluginJson = JSON.parse(pluginJsonRaw); + if (pluginJson.languages && Array.isArray(pluginJson.languages) && pluginJson.languages.length > 0) { + additionsDebug('Found languages in plugin.json, i18n already configured'); + return true; + } + } catch (error) { + additionsDebug('Error parsing plugin.json:', error); + } + + return false; +} + +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); + + // Add languages array + pluginJson.languages = locales; + + // 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; + const pluginName = pluginJson.name || pluginId; + + if (!pluginId) { + additionsDebug('No plugin ID found in plugin.json'); + return; + } + + // Create example translation structure + const exampleTranslations = { + components: { + exampleComponent: { + title: `${pluginName} component title`, + description: 'Example description', + }, + }, + config: { + title: `${pluginName} configuration`, + apiUrl: { + label: 'API URL', + placeholder: 'Enter API URL', + }, + }, + }; + + // Create locale files for each locale + for (const locale of locales) { + const localePath = `src/locales/${locale}/${pluginId}.json`; + + if (!context.doesFileExist(localePath)) { + context.addFile(localePath, JSON.stringify(exampleTranslations, null, 2)); + additionsDebug(`Created ${localePath} with example translations`); + } + } + } 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 for backward compatibility + addDependenciesToPackageJson(context, { semver: '^7.6.0' }, { '@types/semver': '^7.5.0' }); + additionsDebug('Added semver dependency for backward compatibility'); +} + +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 = {}; + } + + // 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'); + } + } 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; + } + + // Check if @grafana/eslint-plugin-plugins is already configured + if (eslintConfigRaw.includes('@grafana/eslint-plugin-plugins')) { + additionsDebug('ESLint i18n rule already configured'); + return; + } + + try { + const ast = recast.parse(eslintConfigRaw, { + parser: require('recast/parsers/babel-ts'), + }); + + // 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('grafanaPluginsPlugin'))], + builders.literal('@grafana/eslint-plugin-plugins') + ); + + 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 plugins config object + const pluginsConfig = builders.objectExpression([ + builders.property( + 'init', + builders.identifier('plugins'), + builders.objectExpression([ + builders.property( + 'init', + builders.literal('grafanaPlugins'), + builders.identifier('grafanaPluginsPlugin') + ), + ]) + ), + builders.property( + 'init', + builders.identifier('rules'), + builders.objectExpression([ + builders.property( + 'init', + builders.literal('grafanaPlugins/no-untranslated-strings'), + builders.literal('warn') + ), + ]) + ), + ]); + + configArray.elements.push(pluginsConfig); + } + 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 { + 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; + } + + // 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: require('recast/parsers/babel-ts'), + }); + + 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: require('recast/parsers/babel-ts'), + }); + + // 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'; + + 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/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) }); + } +} diff --git a/packages/create-plugin/src/bin/run.ts b/packages/create-plugin/src/bin/run.ts index b7cf93ad80..23487f7260 100755 --- a/packages/create-plugin/src/bin/run.ts +++ b/packages/create-plugin/src/bin/run.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node -import minimist from 'minimist'; -import { generate, update, migrate, version, provisioning } from '../commands/index.js'; -import { isUnsupportedPlatform } from '../utils/utils.os.js'; +import { add, generate, migrate, provisioning, update, version } from '../commands/index.js'; import { argv, commandName } from '../utils/utils.cli.js'; + +import { isUnsupportedPlatform } from '../utils/utils.os.js'; +import minimist from 'minimist'; import { output } from '../utils/utils.console.js'; // Exit early if operating system isn't supported. @@ -23,6 +24,7 @@ const commands: Record void> = { update, version, provisioning, + add, }; const command = commands[commandName] || 'generate'; diff --git a/packages/create-plugin/src/commands/add.command.ts b/packages/create-plugin/src/commands/add.command.ts new file mode 100644 index 0000000000..337c1f4146 --- /dev/null +++ b/packages/create-plugin/src/commands/add.command.ts @@ -0,0 +1,108 @@ +import { getAdditionByName, getAvailableAdditions, runAddition } from '../additions/manager.js'; +import { isGitDirectory, isGitDirectoryClean } from '../utils/utils.git.js'; + +import { isPluginDirectory } from '../utils/utils.plugin.js'; +import minimist from 'minimist'; +import { output } from '../utils/utils.console.js'; +import { promptI18nOptions } from './add/prompts.js'; + +export const add = async (argv: minimist.ParsedArgs) => { + const subCommand = argv._[1]; + + if (!subCommand) { + const availableAdditions = getAvailableAdditions(); + const additionsList = Object.values(availableAdditions).map( + (addition) => `${addition.name} - ${addition.description}` + ); + + output.error({ + title: 'No addition specified', + body: [ + 'Usage: npx @grafana/create-plugin add ', + '', + 'Available additions:', + ...output.bulletList(additionsList), + ], + }); + process.exit(1); + } + + await performPreAddChecks(argv); + + const addition = getAdditionByName(subCommand); + + if (!addition) { + const availableAdditions = getAvailableAdditions(); + const additionsList = Object.values(availableAdditions).map((addition) => addition.name); + + output.error({ + title: `Unknown addition: ${subCommand}`, + body: ['Available additions:', ...output.bulletList(additionsList)], + }); + process.exit(1); + } + + try { + // Gather options based on the addition type + let options = {}; + + switch (addition.name) { + case 'i18n': + options = await promptI18nOptions(); + break; + default: + break; + } + + const commitChanges = argv.commit; + await runAddition(addition, options, { commitChanges }); + } catch (error) { + if (error instanceof Error) { + output.error({ + title: 'Addition failed', + body: [error.message], + }); + } + process.exit(1); + } +}; + +async function performPreAddChecks(argv: minimist.ParsedArgs) { + if (!(await isGitDirectory()) && !argv.force) { + output.error({ + title: 'You are not inside a git directory', + body: [ + `In order to proceed please run ${output.formatCode('git init')} in the root of your project and commit your changes.`, + `(This check is necessary to make sure that the changes are easy to revert and don't interfere with any changes you currently have.`, + `In case you want to proceed as is please use the ${output.formatCode('--force')} flag.)`, + ], + }); + + process.exit(1); + } + + if (!(await isGitDirectoryClean()) && !argv.force) { + output.error({ + title: 'Please clean your repository working tree before adding features.', + body: [ + 'Commit your changes or stash them.', + `(This check is necessary to make sure that the changes are easy to revert and don't mess with any changes you currently have.`, + `In case you want to proceed as is please use the ${output.formatCode('--force')} flag.)`, + ], + }); + + process.exit(1); + } + + if (!isPluginDirectory() && !argv.force) { + output.error({ + title: 'Are you inside a plugin directory?', + body: [ + `We couldn't find a "src/plugin.json" file under your current directory.`, + `(Please make sure to run this command from the root of your plugin folder. In case you want to proceed as is please use the ${output.formatCode('--force')} flag.)`, + ], + }); + + process.exit(1); + } +} diff --git a/packages/create-plugin/src/commands/add/prompts.ts b/packages/create-plugin/src/commands/add/prompts.ts new file mode 100644 index 0000000000..ffedd8814f --- /dev/null +++ b/packages/create-plugin/src/commands/add/prompts.ts @@ -0,0 +1,93 @@ +import Enquirer from 'enquirer'; +import { output } from '../../utils/utils.console.js'; + +// Common locales supported by Grafana +// Reference: https://github.com/grafana/grafana/blob/main/packages/grafana-i18n/src/constants.ts +const COMMON_LOCALES = [ + { name: 'en-US', message: 'English (US)' }, + { name: 'es-ES', message: 'Spanish (Spain)' }, + { name: 'fr-FR', message: 'French (France)' }, + { name: 'de-DE', message: 'German (Germany)' }, + { name: 'zh-Hans', message: 'Chinese (Simplified)' }, + { name: 'pt-BR', message: 'Portuguese (Brazil)' }, + { name: 'sv-SE', message: 'Swedish (Sweden)' }, + { name: 'nl-NL', message: 'Dutch (Netherlands)' }, + { name: 'ja-JP', message: 'Japanese (Japan)' }, + { name: 'it-IT', message: 'Italian (Italy)' }, +]; + +export type I18nOptions = { + locales: string[]; +}; + +export async function promptI18nOptions(): Promise { + const enquirer = new Enquirer(); + + output.log({ + title: 'Configure internationalization (i18n) for your plugin', + body: [ + 'Select the locales you want to support. At least one locale must be selected.', + 'Use space to select, enter to continue.', + ], + }); + + const localeChoices = COMMON_LOCALES.map((locale) => ({ + name: locale.name, + message: locale.message, + value: locale.name, + })); + + let selectedLocales: string[] = []; + + try { + const result = (await enquirer.prompt({ + type: 'multiselect', + name: 'locales', + message: 'Select locales to support:', + choices: localeChoices, + initial: [0], // Pre-select en-US by default + validate(value: string[]) { + if (value.length === 0) { + return 'At least one locale must be selected'; + } + return true; + }, + } as any)) as { locales: string[] }; + + selectedLocales = result.locales; + } catch (error) { + // User cancelled the prompt + output.warning({ title: 'Addition cancelled by user.' }); + process.exit(0); + } + + // Ask if they want to add additional locales + try { + const addMoreResult = (await enquirer.prompt({ + type: 'input', + name: 'additionalLocales', + message: 'Enter additional locale codes (comma-separated, e.g., "ko-KR,ru-RU") or press enter to skip:', + } as any)) as { additionalLocales: string }; + + const additionalLocalesInput = addMoreResult.additionalLocales; + + if (additionalLocalesInput && additionalLocalesInput.trim()) { + const additionalLocales = additionalLocalesInput + .split(',') + .map((locale: string) => locale.trim()) + .filter((locale: string) => locale.length > 0 && !selectedLocales.includes(locale)); + + selectedLocales.push(...additionalLocales); + } + } catch (error) { + // User cancelled, just continue with what we have + } + + output.log({ + title: `Selected locales: ${selectedLocales.join(', ')}`, + }); + + return { + locales: selectedLocales, + }; +} diff --git a/packages/create-plugin/src/commands/index.ts b/packages/create-plugin/src/commands/index.ts index ed777a4801..4f25ac2ee0 100644 --- a/packages/create-plugin/src/commands/index.ts +++ b/packages/create-plugin/src/commands/index.ts @@ -3,3 +3,4 @@ export * from './update.command.js'; export * from './migrate.command.js'; export * from './version.command.js'; export * from './provisioning.command.js'; +export * from './add.command.js'; diff --git a/rollup.config.ts b/rollup.config.ts index 8b1ac6aa0e..6fc578106c 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -1,15 +1,16 @@ -import commonjs from '@rollup/plugin-commonjs'; -import json from '@rollup/plugin-json'; -import { nodeResolve } from '@rollup/plugin-node-resolve'; -import { glob, GlobOptions } from 'glob'; -import { readFileSync } from 'node:fs'; +import { GlobOptions, glob } from 'glob'; +import { Plugin, RollupOptions, defineConfig } from 'rollup'; + import { chmod } from 'node:fs/promises'; -import { join } from 'node:path'; -import { inspect } from 'node:util'; -import { defineConfig, Plugin, RollupOptions } from 'rollup'; +import commonjs from '@rollup/plugin-commonjs'; import del from 'rollup-plugin-delete'; import dts from 'rollup-plugin-dts'; import esbuild from 'rollup-plugin-esbuild'; +import { inspect } from 'node:util'; +import { join } from 'node:path'; +import json from '@rollup/plugin-json'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import { readFileSync } from 'node:fs'; const projectRoot = process.cwd(); const tsconfigPath = join(projectRoot, 'tsconfig.json'); @@ -34,13 +35,22 @@ if (pkg.name === '@grafana/plugin-e2e') { // TODO: Remove this once we have a better way to extend this config if (pkg.name === '@grafana/create-plugin') { - const globOptions: GlobOptions = { + const migrationsGlobOptions: GlobOptions = { cwd: join(preserveModulesRoot, 'migrations', 'scripts'), ignore: ['**/*.test.ts'], absolute: true, }; - const migrations = glob.sync('**/*.ts', globOptions).map((m) => m.toString()); + const migrations = glob.sync('**/*.ts', migrationsGlobOptions).map((m) => m.toString()); input.push(...migrations); + + const additionsGlobOptions: GlobOptions = { + cwd: join(preserveModulesRoot, 'additions', 'scripts'), + ignore: ['**/*.test.ts'], + absolute: true, + }; + const additions = glob.sync('**/*.ts', additionsGlobOptions).map((a) => a.toString()); + input.push(...additions); + external.push('prettier'); }