@grafana/create-plugin 7.14.0 → 7.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/codemods/additions/prompts/docs-content.md +33 -15
  3. package/dist/codemods/additions/scripts/docs.js +253 -11
  4. package/dist/codemods/migrations/manager.js +6 -0
  5. package/dist/commands/update.command.js +7 -1
  6. package/package.json +1 -1
  7. package/src/codemods/additions/prompts/docs-content.md +33 -15
  8. package/src/codemods/additions/scripts/docs-content.test.ts +11 -2
  9. package/src/codemods/additions/scripts/docs.test.ts +855 -64
  10. package/src/codemods/additions/scripts/docs.ts +394 -10
  11. package/src/codemods/migrations/manager.test.ts +10 -0
  12. package/src/codemods/migrations/manager.ts +10 -0
  13. package/src/commands/update.command.ts +9 -1
  14. package/templates/common/.config/docker-compose-base.yaml +1 -1
  15. package/templates/common/_package.json +6 -6
  16. package/templates/docs/app/docs/AGENTS.md +129 -0
  17. package/templates/docs/app/docs/CLAUDE.md +1 -0
  18. package/templates/docs/app/docs/README.md +20 -0
  19. package/templates/docs/app/docs/configure.md +65 -0
  20. package/templates/docs/app/docs/dashboards.md +29 -0
  21. package/templates/docs/app/docs/index.md +70 -0
  22. package/templates/docs/app/docs/permissions.md +45 -0
  23. package/templates/docs/app/docs/troubleshooting.md +65 -0
  24. package/templates/docs/app/feature-page.md +28 -0
  25. package/templates/docs/app/features-index.md +12 -0
  26. package/templates/docs/datasource/docs/AGENTS.md +123 -0
  27. package/templates/docs/datasource/docs/CLAUDE.md +1 -0
  28. package/templates/docs/datasource/docs/README.md +20 -0
  29. package/templates/docs/datasource/docs/alerting.md +56 -0
  30. package/templates/docs/datasource/docs/annotations.md +68 -0
  31. package/templates/docs/datasource/docs/configure.md +114 -0
  32. package/templates/docs/datasource/docs/dashboards.md +28 -0
  33. package/templates/docs/datasource/docs/index.md +40 -0
  34. package/templates/docs/datasource/docs/query-editor.md +72 -0
  35. package/templates/docs/datasource/docs/template-variables.md +69 -0
  36. package/templates/docs/datasource/docs/troubleshooting.md +73 -0
  37. package/templates/docs/panel/docs/AGENTS.md +6 -0
  38. package/templates/docs/panel/docs/examples.md +1 -1
  39. package/templates/docs/panel/docs/options.md +1 -1
@@ -19,6 +19,8 @@ import {
19
19
  } from '../../utils.agents.js';
20
20
  import { type BumpActionRefsResult, bumpActionRefs } from '../../utils.workflows.js';
21
21
  import { getPackageManagerExecCmd } from '../../../utils/utils.packageManager.js';
22
+ import * as recast from 'recast';
23
+ import * as babelTsParser from 'recast/parsers/babel-ts.js';
22
24
 
23
25
  // an allowlist, because the value becomes a path on disk and is embedded unescaped in a YAML workflow
24
26
  const DOCS_PATH_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]|\/[a-zA-Z0-9_-])*$/;
@@ -57,7 +59,7 @@ type Options = v.InferOutput<typeof schema>;
57
59
  const PLUGIN_DOCS_CLI_VERSION = '^0.8.0';
58
60
 
59
61
  // each type needs a template set under `templates/docs/<type>/`
60
- const SUPPORTED_PLUGIN_TYPES = ['panel'] as const;
62
+ const SUPPORTED_PLUGIN_TYPES = ['panel', 'datasource', 'app'] as const;
61
63
  type SupportedPluginType = (typeof SUPPORTED_PLUGIN_TYPES)[number];
62
64
 
63
65
  export default function docs(context: Context, options: Options): Context {
@@ -68,6 +70,8 @@ export default function docs(context: Context, options: Options): Context {
68
70
  // `templates/docs` is left out of TEMPLATE_PATHS so `generate` ignores it
69
71
  templateDir: join(TEMPLATES_DIR, 'docs', pluginType),
70
72
  commonTemplateDir: join(TEMPLATES_DIR, 'docs', 'common'),
73
+ featurePages: FEATURE_PAGES_BY_TYPE[pluginType],
74
+ pageStubs: pluginType === 'app' ? getAppFeaturePageStubs : undefined,
71
75
  });
72
76
  }
73
77
 
@@ -76,9 +80,23 @@ interface PluginJson {
76
80
  name?: string;
77
81
  id?: string;
78
82
  docsPath?: string;
83
+ annotations?: boolean;
84
+ alerting?: boolean;
85
+ includes?: PluginInclude[];
86
+ roles?: unknown;
87
+ iam?: unknown;
79
88
  [key: string]: unknown;
80
89
  }
81
90
 
91
+ interface PluginInclude {
92
+ type?: string;
93
+ name?: string;
94
+ path?: string;
95
+ role?: string;
96
+ action?: string;
97
+ defaultNav?: boolean;
98
+ }
99
+
82
100
  export interface DocsSetupOptions {
83
101
  context: Context;
84
102
  /** Falls back to the docsPath in `src/plugin.json`, then to `catalog-docs`. */
@@ -87,6 +105,37 @@ export interface DocsSetupOptions {
87
105
  templateDir: string;
88
106
  /** Templates shared by every plugin type, under `templates/docs/common/`. */
89
107
  commonTemplateDir: string;
108
+ /** Pages scaffolded only when `src/plugin.json` declares the feature they document. */
109
+ featurePages?: FeaturePage[];
110
+ /** Pages rendered from one template per entry, such as a page for each app page in `src/plugin.json`. */
111
+ /** Gets each feature page's `templateFlag`, so the stubs agree with the pages scaffolded for those features. */
112
+ pageStubs?: (pluginJson: PluginJson, featureFlags: Record<string, boolean>) => PageStubs;
113
+ }
114
+
115
+ interface FeaturePage {
116
+ /** Page name without `.md`. Plugin-docs-cli also accepts it as a folder of that name. */
117
+ page: string;
118
+ templateFlag: string;
119
+ hasFeature: (pluginJson: PluginJson, context: Context) => boolean;
120
+ }
121
+
122
+ interface PageStubs {
123
+ /** Rendered into the template data of every page, so the fixed pages can link to the stubs. */
124
+ data: Record<string, unknown>;
125
+ stubs: PageStub[];
126
+ /** Entries in `src/plugin.json` that get no page, as `name (path)`. */
127
+ skipped: string[];
128
+ /** The file name an app's only page gets, which is still there when the app grows to more pages. */
129
+ appPageSlug?: string;
130
+ }
131
+
132
+ interface PageStub {
133
+ templatePath: string;
134
+ /** Relative to the docs folder. */
135
+ file: string;
136
+ /** Set for a page of the app, which a re-run finds in either layout and as a file or a folder. */
137
+ slug?: string;
138
+ data: Record<string, unknown>;
90
139
  }
91
140
 
92
141
  export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
@@ -128,6 +177,13 @@ export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
128
177
  }
129
178
 
130
179
  const templateData = getTemplateDataFromContext(context);
180
+ const featurePages = (opts.featurePages ?? []).map(({ page, templateFlag, hasFeature }) => ({
181
+ page,
182
+ templateFlag,
183
+ isEnabled: hasFeature(pluginJson, context),
184
+ }));
185
+ const featureFlags = Object.fromEntries(featurePages.map(({ templateFlag, isEnabled }) => [templateFlag, isEnabled]));
186
+ const pageStubs = opts.pageStubs?.(pluginJson, featureFlags) ?? { data: {}, stubs: [], skipped: [] };
131
187
  const { packageManagerName, packageManagerVersion } = templateData;
132
188
  const createPluginCmd = `${getPackageManagerExecCmd(packageManagerName, packageManagerVersion)}@latest`;
133
189
  const data = {
@@ -136,6 +192,8 @@ export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
136
192
  docsPath,
137
193
  createPluginCmd,
138
194
  lockFile: LOCK_FILES[packageManagerName],
195
+ ...featureFlags,
196
+ ...pageStubs.data,
139
197
  };
140
198
 
141
199
  addDependenciesToPackageJson(context, {}, { '@grafana/plugin-docs-cli': PLUGIN_DOCS_CLI_VERSION });
@@ -144,12 +202,29 @@ export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
144
202
 
145
203
  // pages are only rendered into a folder without any, so pages the author deleted stay deleted
146
204
  const hasPages = hasDocsPages(context, docsPath);
205
+ const disabledPageFiles = featurePages.filter(({ isEnabled }) => !isEnabled).map(({ page }) => `${page}.md`);
147
206
  addTemplateFolder(context, {
148
207
  folderPath: join(templateDir, 'docs'),
149
208
  targetPath: docsPath,
150
209
  data,
151
- include: (file) => !hasPages || SCAFFOLD_META_FILES.includes(file),
210
+ include: (file) => SCAFFOLD_META_FILES.includes(file) || (!hasPages && !disabledPageFiles.includes(file)),
152
211
  });
212
+ const report: PageReport = {
213
+ missingPageStubs: scaffoldPageStubs({ context, docsPath, stubs: pageStubs.stubs, data, hasPages }),
214
+ // a feature turned on after the first scaffold gets no page, so point the author at it instead
215
+ missingFeaturePages: hasPages
216
+ ? featurePages
217
+ .filter(({ page, isEnabled }) => isEnabled && !pageExists(context, docsPath, [page]))
218
+ .map(({ page }) => `${page}.md`)
219
+ : [],
220
+ // a re-run has no pages to compare against, so it only reports what the first scaffold left out
221
+ skippedPages: hasPages ? [] : pageStubs.skipped,
222
+ };
223
+ const { appPageSlug } = pageStubs;
224
+ if (appPageSlug && report.missingPageStubs.length > 0) {
225
+ // the author may have moved it into features/ or turned it into a folder
226
+ report.appNamedPage = findPage(context, docsPath, [appPageSlug, `features/${appPageSlug}`]);
227
+ }
153
228
 
154
229
  // left alone when it exists, since the author may have customized it
155
230
  const workflowPath = '.github/workflows/validate-docs.yml';
@@ -166,24 +241,91 @@ export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
166
241
  ensureConfigAgentFiles(context, TEMPLATE_PATHS.common, CONFIG_AGENT_FILES, data);
167
242
  appendDocsPointerToInstructions(context, docsPath);
168
243
 
169
- // deferred so it prints after the runner's change list, and only when something changed
170
- if (context.hasChanges()) {
244
+ // deferred so it prints after the runner's change list, and only when there is something to report
245
+ if (context.hasChanges() || report.missingFeaturePages.length > 0 || report.missingPageStubs.length > 0) {
171
246
  context.setMessage(
172
- buildNextStepsMessage({ docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles })
247
+ buildNextStepsMessage({ docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles, report })
173
248
  );
174
249
  }
175
250
 
176
251
  return context;
177
252
  }
178
253
 
254
+ interface PageReport {
255
+ /** Feature pages that plugin.json declares and the docs folder doesn't have, as `<page>.md`. */
256
+ missingFeaturePages: string[];
257
+ /** Pages from `pageStubs` that the docs folder doesn't have, as paths. */
258
+ missingPageStubs: string[];
259
+ /** Entries in plugin.json that got no page on the first scaffold, with the reason. */
260
+ skippedPages: string[];
261
+ /** The page named after the app, when the app has grown past one page and pages are missing. */
262
+ appNamedPage?: string;
263
+ }
264
+
265
+ // a page counts as there as a file or as a folder, so authors can turn a page into a folder
266
+ function pageExists(context: Context, docsPath: string, pageNames: string[]): boolean {
267
+ return findPage(context, docsPath, pageNames) !== undefined;
268
+ }
269
+
270
+ /** The path of the first page that exists, as `<name>.md` or as the folder `<name>/`. */
271
+ function findPage(context: Context, docsPath: string, pageNames: string[]): string | undefined {
272
+ for (const name of pageNames) {
273
+ if (context.doesFileExist(`${docsPath}/${name}.md`)) {
274
+ return `${docsPath}/${name}.md`;
275
+ }
276
+ if (context.doesFileExist(`${docsPath}/${name}`)) {
277
+ return `${docsPath}/${name}/`;
278
+ }
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ /**
284
+ * Renders each stub into a docs folder that has no pages yet. When the folder has pages it adds nothing and
285
+ * returns the stubs it doesn't have, so a page the author deleted stays deleted.
286
+ */
287
+ function scaffoldPageStubs(opts: {
288
+ context: Context;
289
+ docsPath: string;
290
+ stubs: PageStub[];
291
+ data: Record<string, unknown>;
292
+ hasPages: boolean;
293
+ }): string[] {
294
+ const { context, docsPath, stubs, data, hasPages } = opts;
295
+ const missingPages: string[] = [];
296
+ const missingIndexes: string[] = [];
297
+ for (const stub of stubs) {
298
+ const filePath = `${docsPath}/${stub.file}`;
299
+ if (hasPages) {
300
+ // the author may have moved the pages between the root and features/ since the first scaffold
301
+ if (stub.slug !== undefined) {
302
+ if (!pageExists(context, docsPath, [stub.slug, `features/${stub.slug}`])) {
303
+ missingPages.push(filePath);
304
+ }
305
+ } else if (!context.doesFileExist(filePath)) {
306
+ missingIndexes.push(filePath);
307
+ }
308
+ } else if (context.doesFileExist(filePath)) {
309
+ additionsDebug(`${filePath} already exists, skipping`);
310
+ } else {
311
+ context.addFile(filePath, renderTemplate(stub.templatePath, false, { ...data, ...stub.data }));
312
+ }
313
+ }
314
+ // the message names the first one, and a page tells the author more than a folder index
315
+ const missing = [...missingPages, ...missingIndexes];
316
+ return missing;
317
+ }
318
+
179
319
  function buildNextStepsMessage(opts: {
180
320
  docsPath: string;
181
321
  packageManagerName: string;
182
322
  createPluginCmd: string;
183
323
  pluginActions: BumpActionRefsResult;
184
324
  linkedRootFiles: string[];
325
+ report: PageReport;
185
326
  }): ContextMessage {
186
327
  const { docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles } = opts;
328
+ const { missingFeaturePages, missingPageStubs, skippedPages, appNamedPage } = opts.report;
187
329
  const run = (script: string) => output.formatCode(`${packageManagerName} run ${script}`);
188
330
 
189
331
  const steps: NextStep[] = [
@@ -219,6 +361,26 @@ function buildNextStepsMessage(opts: {
219
361
  detail: `Docs need ${REQUIRED_ACTION_REFS[action]} or later.`,
220
362
  });
221
363
  }
364
+ for (const page of missingFeaturePages) {
365
+ warnings.push({
366
+ title: `src/plugin.json declares the feature ${docsPath}/${page} documents, but the page doesn't exist`,
367
+ detail: `If it needs docs, add the page as described under Optional pages in ${docsPath}/AGENTS.md. If you deleted it on purpose, ignore this.`,
368
+ });
369
+ }
370
+ if (missingPageStubs.length > 0) {
371
+ warnings.push({
372
+ title: `src/plugin.json has pages with no docs page, such as ${missingPageStubs[0]}`,
373
+ detail: appNamedPage
374
+ ? `${appNamedPage} is named after the app, which happens while the app has one page. Rename it after the page it documents, then add pages for the others as described under Optional pages in ${docsPath}/AGENTS.md. If you deleted pages on purpose, ignore this.`
375
+ : `If they need docs, add them as described under Optional pages in ${docsPath}/AGENTS.md. If you deleted them on purpose, ignore this.`,
376
+ });
377
+ }
378
+ if (skippedPages.length > 0) {
379
+ warnings.push({
380
+ title: `Left out of the docs: ${skippedPages.join(', ')}`,
381
+ detail: `If they need docs, add them as described under Optional pages in ${docsPath}/AGENTS.md.`,
382
+ });
383
+ }
222
384
  for (const file of linkedRootFiles) {
223
385
  warnings.push({
224
386
  title: `Added a pointer to ${AGENT_INSTRUCTIONS_PATH} at the end of ${file}`,
@@ -251,11 +413,6 @@ export function assertSupportedPluginType(context: Context, additionName = 'docs
251
413
  }
252
414
 
253
415
  const supported = SUPPORTED_PLUGIN_TYPES.join(', ');
254
- if (type === 'app' || type === 'datasource') {
255
- throw new Error(
256
- `create-plugin add ${additionName} does not support '${type}' plugins yet. Supported so far: ${supported}.`
257
- );
258
- }
259
416
  throw new Error(
260
417
  `create-plugin add ${additionName} needs a plugin type of ${supported} in src/plugin.json, but found '${type ?? 'unset'}'.`
261
418
  );
@@ -269,6 +426,233 @@ function isSupportedPluginType(type: string | undefined): type is SupportedPlugi
269
426
  const CONFIG_AGENT_FILES = ['instructions.md', 'e2e-testing.md'];
270
427
  // the scaffold's files that aren't pages, so they're added on every run
271
428
  const SCAFFOLD_META_FILES = ['README.md', 'AGENTS.md', 'CLAUDE.md'];
429
+ const DASHBOARDS_PAGE: FeaturePage = {
430
+ page: 'dashboards',
431
+ templateFlag: 'hasDashboards',
432
+ hasFeature: (p) => getIncludes(p).some((i) => i.type === 'dashboard'),
433
+ };
434
+ const FEATURE_PAGES_BY_TYPE: Record<SupportedPluginType, FeaturePage[]> = {
435
+ panel: [],
436
+ datasource: [
437
+ { page: 'annotations', templateFlag: 'hasAnnotations', hasFeature: (p) => p.annotations === true },
438
+ { page: 'alerting', templateFlag: 'hasAlerting', hasFeature: (p) => p.alerting === true },
439
+ DASHBOARDS_PAGE,
440
+ ],
441
+ app: [
442
+ { page: 'configure', templateFlag: 'hasConfigPage', hasFeature: hasAppConfigPage },
443
+ DASHBOARDS_PAGE,
444
+ {
445
+ page: 'permissions',
446
+ templateFlag: 'hasPermissions',
447
+ hasFeature: (p) =>
448
+ p.roles !== undefined ||
449
+ p.iam !== undefined ||
450
+ getIncludes(p).some((i) => (i.role !== undefined && i.role !== 'Viewer') || i.action !== undefined),
451
+ },
452
+ ],
453
+ };
454
+
455
+ function getIncludes(pluginJson: PluginJson): PluginInclude[] {
456
+ return Array.isArray(pluginJson.includes)
457
+ ? pluginJson.includes.filter((i): i is PluginInclude => typeof i === 'object' && i !== null)
458
+ : [];
459
+ }
460
+
461
+ // app page paths may use the %PLUGIN_ID% placeholder that the build replaces
462
+ function isAppPath(path: string | undefined, pluginId: string | undefined, prefix: string): boolean {
463
+ return [pluginId, '%PLUGIN_ID%'].some(
464
+ (id) => id && (path === `${prefix}${id}` || path?.startsWith(`${prefix}${id}/`))
465
+ );
466
+ }
467
+
468
+ const APP_MODULE_FILES = ['src/module.ts', 'src/module.tsx', 'src/module.js', 'src/module.jsx'];
469
+
470
+ function hasAppConfigPage(pluginJson: PluginJson, context: Context): boolean {
471
+ return (
472
+ APP_MODULE_FILES.some((file) => callsAddConfigPage(context.getFile(file))) ||
473
+ getIncludes(pluginJson).some((i) => i.type === 'page' && isAppPath(i.path, pluginJson.id, '/plugins/'))
474
+ );
475
+ }
476
+
477
+ function callsAddConfigPage(source: string | undefined): boolean {
478
+ if (!source) {
479
+ return false;
480
+ }
481
+ // babel-ts parses JSX as well as TypeScript, since app modules are often .tsx
482
+ let ast: recast.types.ASTNode;
483
+ try {
484
+ ast = recast.parse(source, { parser: babelTsParser });
485
+ } catch (error) {
486
+ additionsDebug(`Failed to parse the app module, so it can't tell whether it has a config page: ${error}`);
487
+ return false;
488
+ }
489
+ let isFound = false;
490
+ recast.visit(ast, {
491
+ visitCallExpression(path) {
492
+ const { callee } = path.node;
493
+ if (
494
+ callee.type === 'MemberExpression' &&
495
+ callee.property.type === 'Identifier' &&
496
+ callee.property.name === 'addConfigPage'
497
+ ) {
498
+ isFound = true;
499
+ return false;
500
+ }
501
+ this.traverse(path);
502
+ return undefined;
503
+ },
504
+ });
505
+ return isFound;
506
+ }
507
+
508
+ // the example pages templates/app/src/plugin.json ships, which only document the scaffold
509
+ const TEMPLATE_DEFAULT_PAGES: Record<string, string> = {
510
+ 'Page One': '/one',
511
+ 'Page Two': '/two',
512
+ 'Page Three': '/three',
513
+ 'Page Four': '/four',
514
+ };
515
+
516
+ const CONFIG_PAGE_SLUGS = ['configuration', 'configure'];
517
+
518
+ interface AppFeaturePage {
519
+ /** The name in plugin.json, which is how the app's own navigation labels the page. */
520
+ name: string;
521
+ /** What the docs call the page, which is the app's name when it has no other page. */
522
+ title: string;
523
+ isOnlyPage?: boolean;
524
+ slug: string;
525
+ path: string;
526
+ role?: string;
527
+ }
528
+
529
+ // pages under /plugins/ are the config page and pages under /d/ link to a bundled dashboard, so neither gets a page
530
+ function getAppFeaturePages(
531
+ pluginJson: PluginJson,
532
+ hasConfigPage: boolean
533
+ ): { pages: AppFeaturePage[]; configPage?: AppFeaturePage; skipped: string[] } {
534
+ const pages: AppFeaturePage[] = [];
535
+ let configPage: AppFeaturePage | undefined;
536
+ const skipped: string[] = [];
537
+ const appPages = getIncludes(pluginJson)
538
+ .filter((i) => i.type === 'page' && typeof i.name === 'string' && isAppPath(i.path, pluginJson.id, '/a/'))
539
+ .sort((a, b) => Number(b.defaultNav === true) - Number(a.defaultNav === true));
540
+ const takenSlugs = new Set(getReservedPageSlugs());
541
+ for (const include of appPages) {
542
+ const name = include.name as string;
543
+ const path = (include.path as string).replaceAll('%PLUGIN_ID%', pluginJson.id ?? '');
544
+ const defaultPath = TEMPLATE_DEFAULT_PAGES[name];
545
+ if (defaultPath !== undefined && path.endsWith(defaultPath)) {
546
+ additionsDebug(`${name} is an example page from the create-plugin app template, skipping`);
547
+ skipped.push(`${name} (${include.path}, an example page from the create-plugin app template)`);
548
+ continue;
549
+ }
550
+ const slug = slugify(name);
551
+ if (!slug) {
552
+ additionsDebug(`${name} has no letters or digits for a file name, skipping`);
553
+ skipped.push(`${name} (${include.path}, its name has no letters or digits for a file name)`);
554
+ continue;
555
+ }
556
+ // Viewer is the lowest role, so a page that needs only that has nothing to tell the reader
557
+ const role = typeof include.role === 'string' && include.role !== 'Viewer' ? include.role : undefined;
558
+ // an in-app page named like the config page takes its place, the way data sources name it
559
+ if (!hasConfigPage && !configPage && CONFIG_PAGE_SLUGS.includes(slug)) {
560
+ configPage = { name, title: name, slug: 'configure', path, role };
561
+ continue;
562
+ }
563
+ if (takenSlugs.has(slug)) {
564
+ additionsDebug(`${name} would be written to ${slug}.md, which another page uses, skipping`);
565
+ skipped.push(`${name} (${include.path}, its file name is used by another page)`);
566
+ continue;
567
+ }
568
+ takenSlugs.add(slug);
569
+ pages.push({ name, title: name, slug, path, role });
570
+ }
571
+
572
+ // a single page is the whole app, and its navigation label is often a generic one such as Overview or Home
573
+ const appSlug = getAppPageSlug(pluginJson);
574
+ if (pages.length === 1 && appSlug) {
575
+ pages[0] = { ...pages[0], title: pluginJson.name as string, slug: appSlug, isOnlyPage: true };
576
+ }
577
+ return { pages, configPage, skipped };
578
+ }
579
+
580
+ function getAppPageSlug(pluginJson: PluginJson): string | undefined {
581
+ const slug = slugify(pluginJson.name ?? '');
582
+ return slug && !getReservedPageSlugs().includes(slug) ? slug : undefined;
583
+ }
584
+
585
+ function slugify(name: string): string {
586
+ return name
587
+ .normalize('NFKD')
588
+ .replace(/\p{M}/gu, '')
589
+ .toLowerCase()
590
+ .replace(/[^a-z0-9]+/g, '-')
591
+ .replace(/^-|-$/g, '');
592
+ }
593
+
594
+ // more app pages than this go in a features/ folder, so a small app has no folder with one page in it
595
+ const MAX_FLAT_PAGES = 3;
596
+ // the root pages that come before the app pages, which take the positions after it
597
+ const FIRST_PAGE_POSITION = 3;
598
+ const CONFIG_PAGE_POSITION = 2;
599
+ const FIXED_PAGE_SLUGS = ['index', 'configure', 'dashboards', 'permissions', 'troubleshooting'];
600
+
601
+ // file names an app page can't take, because another page or plugin-docs-cli uses them
602
+ function getReservedPageSlugs(): string[] {
603
+ return [
604
+ ...FIXED_PAGE_SLUGS,
605
+ ...DOCS_CLI_META_FILES.map((file) => file.slice(0, -'.MD'.length).toLowerCase().replaceAll('_', '-')),
606
+ ];
607
+ }
608
+
609
+ function getAppFeaturePageStubs(pluginJson: PluginJson, featureFlags: Record<string, boolean>): PageStubs {
610
+ const { pages, configPage, skipped } = getAppFeaturePages(pluginJson, featureFlags.hasConfigPage === true);
611
+ const templateDir = join(TEMPLATES_DIR, 'docs', 'app');
612
+ const pageTemplate = join(templateDir, 'feature-page.md');
613
+ const stubs: PageStub[] = [];
614
+ if (configPage) {
615
+ stubs.push({
616
+ templatePath: pageTemplate,
617
+ file: 'configure.md',
618
+ slug: 'configure',
619
+ data: { page: { ...configPage, position: CONFIG_PAGE_POSITION } },
620
+ });
621
+ }
622
+
623
+ const appPageSlug = getAppPageSlug(pluginJson);
624
+ const isInFolder = pages.length > MAX_FLAT_PAGES;
625
+ const folder = isInFolder ? 'features/' : '';
626
+ if (isInFolder) {
627
+ stubs.push({
628
+ templatePath: join(templateDir, 'features-index.md'),
629
+ file: 'features/index.md',
630
+ data: { position: FIRST_PAGE_POSITION },
631
+ });
632
+ }
633
+ // plugin-docs-cli compares a folder's index.md with the pages inside it, so these start after it
634
+ const firstPosition = isInFolder ? FIRST_PAGE_POSITION + 1 : FIRST_PAGE_POSITION;
635
+ pages.forEach((page, i) => {
636
+ stubs.push({
637
+ templatePath: pageTemplate,
638
+ file: `${folder}${page.slug}.md`,
639
+ slug: page.slug,
640
+ data: { page: { ...page, position: firstPosition + i } },
641
+ });
642
+ });
643
+
644
+ return {
645
+ data: {
646
+ ...(configPage ? { hasConfigPage: true } : {}),
647
+ ...(pages.length > 0 ? { defaultFeaturePage: { ...pages[0], file: `${folder}${pages[0].slug}` } } : {}),
648
+ },
649
+ stubs,
650
+ skipped,
651
+ // a page that is already called like the app is the page the author wants, not one to rename
652
+ appPageSlug: pages.some((page) => page.slug === appPageSlug) ? undefined : appPageSlug,
653
+ };
654
+ }
655
+
272
656
  // mirrors isMetaFile in @grafana/plugin-docs-cli, so both tools agree on what counts as a page
273
657
  const DOCS_CLI_META_FILES = [
274
658
  'README.MD',
@@ -339,5 +339,15 @@ describe('Migrations', () => {
339
339
 
340
340
  expect(setRootConfig).not.toHaveBeenCalled();
341
341
  });
342
+
343
+ it('should NOT update version in ".config/.cprc.json" if no migrations made changes', async () => {
344
+ migrationOneFn.mockImplementation(async (context: Context) => context);
345
+ migrationTwoFn.mockImplementation(async (context: Context) => context);
346
+
347
+ const didMigrationsChangeFiles = await runMigrations(migrations);
348
+
349
+ expect(didMigrationsChangeFiles).toBe(false);
350
+ expect(setRootConfig).not.toHaveBeenCalled();
351
+ });
342
352
  });
343
353
  });
@@ -57,21 +57,31 @@ export async function runMigrations(migrations: Migration[], options: RunMigrati
57
57
 
58
58
  output.log({ title: 'Running the following migrations:', body: migrationListBody });
59
59
 
60
+ let hasAnyChanges = false;
61
+
60
62
  // run migrations sequentially in version order where lowest version runs first
61
63
  for (const migration of migrations) {
62
64
  const context = await runCodemod(migration, options.codemodOptions);
63
65
  const shouldCommit = options.commitEachMigration && context.hasChanges();
64
66
 
67
+ hasAnyChanges ||= context.hasChanges();
68
+
65
69
  if (shouldCommit) {
66
70
  // for conventional commits we need to add a newline between the title and the description
67
71
  await gitCommitNoVerify(`chore: run create-plugin migration - ${migration.name}\n\n${migration.description}`);
68
72
  }
69
73
  }
70
74
 
75
+ if (!hasAnyChanges) {
76
+ return false;
77
+ }
78
+
71
79
  await setRootConfig({ version: CURRENT_APP_VERSION });
72
80
 
73
81
  // Nothing to commit when only unreleased migrations re-ran on a plugin already on this version.
74
82
  if (options.commitEachMigration && !(await isGitDirectoryClean())) {
75
83
  await gitCommitNoVerify(`chore: update .config/.cprc.json to version ${CURRENT_APP_VERSION}.`);
76
84
  }
85
+
86
+ return true;
77
87
  }
@@ -43,10 +43,18 @@ export const update = async (argv: minimist.ParsedArgs) => {
43
43
 
44
44
  // filter out minimist internal properties (_ and $0) before passing to codemod
45
45
  const { _, $0, ...codemodOptions } = argv;
46
- await runMigrations(migrations, {
46
+ const didMigrationsChangeFiles = await runMigrations(migrations, {
47
47
  commitEachMigration: !!argv.commit,
48
48
  codemodOptions,
49
49
  });
50
+
51
+ if (!didMigrationsChangeFiles) {
52
+ output.log({
53
+ title: 'No migrations resulted in changes.',
54
+ });
55
+ process.exit(0);
56
+ }
57
+
50
58
  output.success({
51
59
  title: `Successfully updated create-plugin from ${version} to ${CURRENT_APP_VERSION}.`,
52
60
  });
@@ -7,7 +7,7 @@ services:
7
7
  context: .
8
8
  args:
9
9
  grafana_image: ${GRAFANA_IMAGE:-{{~grafanaImage~}} }
10
- grafana_version: ${GRAFANA_VERSION:-13.2.3}
10
+ grafana_version: ${GRAFANA_VERSION:-13.2.4}
11
11
  development: ${DEVELOPMENT:-false}
12
12
  anonymous_auth_enabled: ${ANONYMOUS_AUTH_ENABLED:-true}
13
13
  ports:
@@ -17,7 +17,7 @@
17
17
  "license": "Apache-2.0",
18
18
  "devDependencies": {
19
19
  "@grafana/eslint-config": "^10.0.0",
20
- "@grafana/plugin-e2e": "^3.15.0",
20
+ "@grafana/plugin-e2e": "^3.15.1",
21
21
  "@grafana/sign-plugin": "^3.3.3",
22
22
  "@grafana/tsconfig": "^2.2.0",
23
23
  "@playwright/test": "^1.62.1",{{#if useExperimentalRspack}}
@@ -77,11 +77,11 @@
77
77
  },
78
78
  "dependencies": {
79
79
  "@emotion/css": "11.10.6",
80
- "@grafana/data": "13.2.3",
81
- "@grafana/i18n": "13.2.3",
82
- "@grafana/runtime": "13.2.3",
83
- "@grafana/ui": "13.2.3",
84
- "@grafana/schema": "13.2.3",{{#if_eq pluginType "scenesapp" }}
80
+ "@grafana/data": "13.2.4",
81
+ "@grafana/i18n": "13.2.4",
82
+ "@grafana/runtime": "13.2.4",
83
+ "@grafana/ui": "13.2.4",
84
+ "@grafana/schema": "13.2.4",{{#if_eq pluginType "scenesapp" }}
85
85
  "@grafana/scenes": "{{ scenesVersion }}",{{/if_eq}}
86
86
  "react": "^19.0.0",
87
87
  "react-dom": "^19.0.0"{{#if isAppType}},