@grafana/create-plugin 7.14.0 → 7.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/codemods/additions/prompts/docs-content.md +24 -12
  3. package/dist/codemods/additions/scripts/docs.js +229 -11
  4. package/package.json +1 -1
  5. package/src/codemods/additions/prompts/docs-content.md +24 -12
  6. package/src/codemods/additions/scripts/docs-content.test.ts +11 -2
  7. package/src/codemods/additions/scripts/docs.test.ts +793 -64
  8. package/src/codemods/additions/scripts/docs.ts +354 -10
  9. package/templates/docs/app/docs/AGENTS.md +127 -0
  10. package/templates/docs/app/docs/CLAUDE.md +1 -0
  11. package/templates/docs/app/docs/README.md +20 -0
  12. package/templates/docs/app/docs/configure.md +65 -0
  13. package/templates/docs/app/docs/dashboards.md +29 -0
  14. package/templates/docs/app/docs/index.md +70 -0
  15. package/templates/docs/app/docs/permissions.md +45 -0
  16. package/templates/docs/app/docs/troubleshooting.md +65 -0
  17. package/templates/docs/app/feature-page.md +26 -0
  18. package/templates/docs/app/features-index.md +12 -0
  19. package/templates/docs/datasource/docs/AGENTS.md +123 -0
  20. package/templates/docs/datasource/docs/CLAUDE.md +1 -0
  21. package/templates/docs/datasource/docs/README.md +20 -0
  22. package/templates/docs/datasource/docs/alerting.md +56 -0
  23. package/templates/docs/datasource/docs/annotations.md +68 -0
  24. package/templates/docs/datasource/docs/configure.md +114 -0
  25. package/templates/docs/datasource/docs/dashboards.md +28 -0
  26. package/templates/docs/datasource/docs/index.md +40 -0
  27. package/templates/docs/datasource/docs/query-editor.md +72 -0
  28. package/templates/docs/datasource/docs/template-variables.md +69 -0
  29. package/templates/docs/datasource/docs/troubleshooting.md +73 -0
  30. package/templates/docs/panel/docs/AGENTS.md +6 -0
@@ -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,35 @@ 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
+ }
129
+
130
+ interface PageStub {
131
+ templatePath: string;
132
+ /** Relative to the docs folder. */
133
+ file: string;
134
+ /** Set for a page of the app, which a re-run finds in either layout and as a file or a folder. */
135
+ slug?: string;
136
+ data: Record<string, unknown>;
90
137
  }
91
138
 
92
139
  export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
@@ -128,6 +175,13 @@ export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
128
175
  }
129
176
 
130
177
  const templateData = getTemplateDataFromContext(context);
178
+ const featurePages = (opts.featurePages ?? []).map(({ page, templateFlag, hasFeature }) => ({
179
+ page,
180
+ templateFlag,
181
+ isEnabled: hasFeature(pluginJson, context),
182
+ }));
183
+ const featureFlags = Object.fromEntries(featurePages.map(({ templateFlag, isEnabled }) => [templateFlag, isEnabled]));
184
+ const pageStubs = opts.pageStubs?.(pluginJson, featureFlags) ?? { data: {}, stubs: [], skipped: [] };
131
185
  const { packageManagerName, packageManagerVersion } = templateData;
132
186
  const createPluginCmd = `${getPackageManagerExecCmd(packageManagerName, packageManagerVersion)}@latest`;
133
187
  const data = {
@@ -136,6 +190,8 @@ export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
136
190
  docsPath,
137
191
  createPluginCmd,
138
192
  lockFile: LOCK_FILES[packageManagerName],
193
+ ...featureFlags,
194
+ ...pageStubs.data,
139
195
  };
140
196
 
141
197
  addDependenciesToPackageJson(context, {}, { '@grafana/plugin-docs-cli': PLUGIN_DOCS_CLI_VERSION });
@@ -144,12 +200,24 @@ export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
144
200
 
145
201
  // pages are only rendered into a folder without any, so pages the author deleted stay deleted
146
202
  const hasPages = hasDocsPages(context, docsPath);
203
+ const disabledPageFiles = featurePages.filter(({ isEnabled }) => !isEnabled).map(({ page }) => `${page}.md`);
147
204
  addTemplateFolder(context, {
148
205
  folderPath: join(templateDir, 'docs'),
149
206
  targetPath: docsPath,
150
207
  data,
151
- include: (file) => !hasPages || SCAFFOLD_META_FILES.includes(file),
208
+ include: (file) => SCAFFOLD_META_FILES.includes(file) || (!hasPages && !disabledPageFiles.includes(file)),
152
209
  });
210
+ const report: PageReport = {
211
+ missingPageStubs: scaffoldPageStubs({ context, docsPath, stubs: pageStubs.stubs, data, hasPages }),
212
+ // a feature turned on after the first scaffold gets no page, so point the author at it instead
213
+ missingFeaturePages: hasPages
214
+ ? featurePages
215
+ .filter(({ page, isEnabled }) => isEnabled && !pageExists(context, docsPath, [page]))
216
+ .map(({ page }) => `${page}.md`)
217
+ : [],
218
+ // a re-run has no pages to compare against, so it only reports what the first scaffold left out
219
+ skippedPages: hasPages ? [] : pageStubs.skipped,
220
+ };
153
221
 
154
222
  // left alone when it exists, since the author may have customized it
155
223
  const workflowPath = '.github/workflows/validate-docs.yml';
@@ -166,24 +234,78 @@ export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
166
234
  ensureConfigAgentFiles(context, TEMPLATE_PATHS.common, CONFIG_AGENT_FILES, data);
167
235
  appendDocsPointerToInstructions(context, docsPath);
168
236
 
169
- // deferred so it prints after the runner's change list, and only when something changed
170
- if (context.hasChanges()) {
237
+ // deferred so it prints after the runner's change list, and only when there is something to report
238
+ if (context.hasChanges() || report.missingFeaturePages.length > 0 || report.missingPageStubs.length > 0) {
171
239
  context.setMessage(
172
- buildNextStepsMessage({ docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles })
240
+ buildNextStepsMessage({ docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles, report })
173
241
  );
174
242
  }
175
243
 
176
244
  return context;
177
245
  }
178
246
 
247
+ interface PageReport {
248
+ /** Feature pages that plugin.json declares and the docs folder doesn't have, as `<page>.md`. */
249
+ missingFeaturePages: string[];
250
+ /** Pages from `pageStubs` that the docs folder doesn't have, as paths. */
251
+ missingPageStubs: string[];
252
+ /** Entries in plugin.json that got no page on the first scaffold, with the reason. */
253
+ skippedPages: string[];
254
+ }
255
+
256
+ // a page counts as there as a file or as a folder, so authors can turn a page into a folder
257
+ function pageExists(context: Context, docsPath: string, pageNames: string[]): boolean {
258
+ return pageNames.some(
259
+ (name) => context.doesFileExist(`${docsPath}/${name}.md`) || context.doesFileExist(`${docsPath}/${name}`)
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Renders each stub into a docs folder that has no pages yet. When the folder has pages it adds nothing and
265
+ * returns the stubs it doesn't have, so a page the author deleted stays deleted.
266
+ */
267
+ function scaffoldPageStubs(opts: {
268
+ context: Context;
269
+ docsPath: string;
270
+ stubs: PageStub[];
271
+ data: Record<string, unknown>;
272
+ hasPages: boolean;
273
+ }): string[] {
274
+ const { context, docsPath, stubs, data, hasPages } = opts;
275
+ const missingPages: string[] = [];
276
+ const missingIndexes: string[] = [];
277
+ for (const stub of stubs) {
278
+ const filePath = `${docsPath}/${stub.file}`;
279
+ if (hasPages) {
280
+ // the author may have moved the pages between the root and features/ since the first scaffold
281
+ if (stub.slug !== undefined) {
282
+ if (!pageExists(context, docsPath, [stub.slug, `features/${stub.slug}`])) {
283
+ missingPages.push(filePath);
284
+ }
285
+ } else if (!context.doesFileExist(filePath)) {
286
+ missingIndexes.push(filePath);
287
+ }
288
+ } else if (context.doesFileExist(filePath)) {
289
+ additionsDebug(`${filePath} already exists, skipping`);
290
+ } else {
291
+ context.addFile(filePath, renderTemplate(stub.templatePath, false, { ...data, ...stub.data }));
292
+ }
293
+ }
294
+ // the message names the first one, and a page tells the author more than a folder index
295
+ const missing = [...missingPages, ...missingIndexes];
296
+ return missing;
297
+ }
298
+
179
299
  function buildNextStepsMessage(opts: {
180
300
  docsPath: string;
181
301
  packageManagerName: string;
182
302
  createPluginCmd: string;
183
303
  pluginActions: BumpActionRefsResult;
184
304
  linkedRootFiles: string[];
305
+ report: PageReport;
185
306
  }): ContextMessage {
186
307
  const { docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles } = opts;
308
+ const { missingFeaturePages, missingPageStubs, skippedPages } = opts.report;
187
309
  const run = (script: string) => output.formatCode(`${packageManagerName} run ${script}`);
188
310
 
189
311
  const steps: NextStep[] = [
@@ -219,6 +341,24 @@ function buildNextStepsMessage(opts: {
219
341
  detail: `Docs need ${REQUIRED_ACTION_REFS[action]} or later.`,
220
342
  });
221
343
  }
344
+ for (const page of missingFeaturePages) {
345
+ warnings.push({
346
+ title: `src/plugin.json declares the feature ${docsPath}/${page} documents, but the page doesn't exist`,
347
+ 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.`,
348
+ });
349
+ }
350
+ if (missingPageStubs.length > 0) {
351
+ warnings.push({
352
+ title: `src/plugin.json has pages with no docs page, such as ${missingPageStubs[0]}`,
353
+ detail: `If they need docs, add them as described under Optional pages in ${docsPath}/AGENTS.md. If you deleted them on purpose, ignore this.`,
354
+ });
355
+ }
356
+ if (skippedPages.length > 0) {
357
+ warnings.push({
358
+ title: `Left out of the docs: ${skippedPages.join(', ')}`,
359
+ detail: `If they need docs, add them as described under Optional pages in ${docsPath}/AGENTS.md.`,
360
+ });
361
+ }
222
362
  for (const file of linkedRootFiles) {
223
363
  warnings.push({
224
364
  title: `Added a pointer to ${AGENT_INSTRUCTIONS_PATH} at the end of ${file}`,
@@ -251,11 +391,6 @@ export function assertSupportedPluginType(context: Context, additionName = 'docs
251
391
  }
252
392
 
253
393
  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
394
  throw new Error(
260
395
  `create-plugin add ${additionName} needs a plugin type of ${supported} in src/plugin.json, but found '${type ?? 'unset'}'.`
261
396
  );
@@ -269,6 +404,215 @@ function isSupportedPluginType(type: string | undefined): type is SupportedPlugi
269
404
  const CONFIG_AGENT_FILES = ['instructions.md', 'e2e-testing.md'];
270
405
  // the scaffold's files that aren't pages, so they're added on every run
271
406
  const SCAFFOLD_META_FILES = ['README.md', 'AGENTS.md', 'CLAUDE.md'];
407
+ const DASHBOARDS_PAGE: FeaturePage = {
408
+ page: 'dashboards',
409
+ templateFlag: 'hasDashboards',
410
+ hasFeature: (p) => getIncludes(p).some((i) => i.type === 'dashboard'),
411
+ };
412
+ const FEATURE_PAGES_BY_TYPE: Record<SupportedPluginType, FeaturePage[]> = {
413
+ panel: [],
414
+ datasource: [
415
+ { page: 'annotations', templateFlag: 'hasAnnotations', hasFeature: (p) => p.annotations === true },
416
+ { page: 'alerting', templateFlag: 'hasAlerting', hasFeature: (p) => p.alerting === true },
417
+ DASHBOARDS_PAGE,
418
+ ],
419
+ app: [
420
+ { page: 'configure', templateFlag: 'hasConfigPage', hasFeature: hasAppConfigPage },
421
+ DASHBOARDS_PAGE,
422
+ {
423
+ page: 'permissions',
424
+ templateFlag: 'hasPermissions',
425
+ hasFeature: (p) =>
426
+ p.roles !== undefined ||
427
+ p.iam !== undefined ||
428
+ getIncludes(p).some((i) => (i.role !== undefined && i.role !== 'Viewer') || i.action !== undefined),
429
+ },
430
+ ],
431
+ };
432
+
433
+ function getIncludes(pluginJson: PluginJson): PluginInclude[] {
434
+ return Array.isArray(pluginJson.includes)
435
+ ? pluginJson.includes.filter((i): i is PluginInclude => typeof i === 'object' && i !== null)
436
+ : [];
437
+ }
438
+
439
+ // app page paths may use the %PLUGIN_ID% placeholder that the build replaces
440
+ function isAppPath(path: string | undefined, pluginId: string | undefined, prefix: string): boolean {
441
+ return [pluginId, '%PLUGIN_ID%'].some(
442
+ (id) => id && (path === `${prefix}${id}` || path?.startsWith(`${prefix}${id}/`))
443
+ );
444
+ }
445
+
446
+ const APP_MODULE_FILES = ['src/module.ts', 'src/module.tsx', 'src/module.js', 'src/module.jsx'];
447
+
448
+ function hasAppConfigPage(pluginJson: PluginJson, context: Context): boolean {
449
+ return (
450
+ APP_MODULE_FILES.some((file) => callsAddConfigPage(context.getFile(file))) ||
451
+ getIncludes(pluginJson).some((i) => i.type === 'page' && isAppPath(i.path, pluginJson.id, '/plugins/'))
452
+ );
453
+ }
454
+
455
+ function callsAddConfigPage(source: string | undefined): boolean {
456
+ if (!source) {
457
+ return false;
458
+ }
459
+ // babel-ts parses JSX as well as TypeScript, since app modules are often .tsx
460
+ let ast: recast.types.ASTNode;
461
+ try {
462
+ ast = recast.parse(source, { parser: babelTsParser });
463
+ } catch (error) {
464
+ additionsDebug(`Failed to parse the app module, so it can't tell whether it has a config page: ${error}`);
465
+ return false;
466
+ }
467
+ let isFound = false;
468
+ recast.visit(ast, {
469
+ visitCallExpression(path) {
470
+ const { callee } = path.node;
471
+ if (
472
+ callee.type === 'MemberExpression' &&
473
+ callee.property.type === 'Identifier' &&
474
+ callee.property.name === 'addConfigPage'
475
+ ) {
476
+ isFound = true;
477
+ return false;
478
+ }
479
+ this.traverse(path);
480
+ return undefined;
481
+ },
482
+ });
483
+ return isFound;
484
+ }
485
+
486
+ // the example pages templates/app/src/plugin.json ships, which only document the scaffold
487
+ const TEMPLATE_DEFAULT_PAGES: Record<string, string> = {
488
+ 'Page One': '/one',
489
+ 'Page Two': '/two',
490
+ 'Page Three': '/three',
491
+ 'Page Four': '/four',
492
+ };
493
+
494
+ const CONFIG_PAGE_SLUGS = ['configuration', 'configure'];
495
+
496
+ interface AppFeaturePage {
497
+ name: string;
498
+ slug: string;
499
+ path: string;
500
+ role?: string;
501
+ }
502
+
503
+ // pages under /plugins/ are the config page and pages under /d/ link to a bundled dashboard, so neither gets a page
504
+ function getAppFeaturePages(
505
+ pluginJson: PluginJson,
506
+ hasConfigPage: boolean
507
+ ): { pages: AppFeaturePage[]; configPage?: AppFeaturePage; skipped: string[] } {
508
+ const pages: AppFeaturePage[] = [];
509
+ let configPage: AppFeaturePage | undefined;
510
+ const skipped: string[] = [];
511
+ const appPages = getIncludes(pluginJson)
512
+ .filter((i) => i.type === 'page' && typeof i.name === 'string' && isAppPath(i.path, pluginJson.id, '/a/'))
513
+ .sort((a, b) => Number(b.defaultNav === true) - Number(a.defaultNav === true));
514
+ const takenSlugs = new Set(getReservedPageSlugs());
515
+ for (const include of appPages) {
516
+ const name = include.name as string;
517
+ const path = (include.path as string).replaceAll('%PLUGIN_ID%', pluginJson.id ?? '');
518
+ const defaultPath = TEMPLATE_DEFAULT_PAGES[name];
519
+ if (defaultPath !== undefined && path.endsWith(defaultPath)) {
520
+ additionsDebug(`${name} is an example page from the create-plugin app template, skipping`);
521
+ skipped.push(`${name} (${include.path}, an example page from the create-plugin app template)`);
522
+ continue;
523
+ }
524
+ const slug = slugify(name);
525
+ if (!slug) {
526
+ additionsDebug(`${name} has no letters or digits for a file name, skipping`);
527
+ skipped.push(`${name} (${include.path}, its name has no letters or digits for a file name)`);
528
+ continue;
529
+ }
530
+ // Viewer is the lowest role, so a page that needs only that has nothing to tell the reader
531
+ const role = typeof include.role === 'string' && include.role !== 'Viewer' ? include.role : undefined;
532
+ // an in-app page named like the config page takes its place, the way data sources name it
533
+ if (!hasConfigPage && !configPage && CONFIG_PAGE_SLUGS.includes(slug)) {
534
+ configPage = { name, slug: 'configure', path, role };
535
+ continue;
536
+ }
537
+ if (takenSlugs.has(slug)) {
538
+ additionsDebug(`${name} would be written to ${slug}.md, which another page uses, skipping`);
539
+ skipped.push(`${name} (${include.path}, its file name is used by another page)`);
540
+ continue;
541
+ }
542
+ takenSlugs.add(slug);
543
+ pages.push({ name, slug, path, role });
544
+ }
545
+ return { pages, configPage, skipped };
546
+ }
547
+
548
+ function slugify(name: string): string {
549
+ return name
550
+ .normalize('NFKD')
551
+ .replace(/\p{M}/gu, '')
552
+ .toLowerCase()
553
+ .replace(/[^a-z0-9]+/g, '-')
554
+ .replace(/^-|-$/g, '');
555
+ }
556
+
557
+ // more app pages than this go in a features/ folder, so a small app has no folder with one page in it
558
+ const MAX_FLAT_PAGES = 3;
559
+ // the root pages that come before the app pages, which take the positions after it
560
+ const FIRST_PAGE_POSITION = 3;
561
+ const CONFIG_PAGE_POSITION = 2;
562
+ const FIXED_PAGE_SLUGS = ['index', 'configure', 'dashboards', 'permissions', 'troubleshooting'];
563
+
564
+ // file names an app page can't take, because another page or plugin-docs-cli uses them
565
+ function getReservedPageSlugs(): string[] {
566
+ return [
567
+ ...FIXED_PAGE_SLUGS,
568
+ ...DOCS_CLI_META_FILES.map((file) => file.slice(0, -'.MD'.length).toLowerCase().replaceAll('_', '-')),
569
+ ];
570
+ }
571
+
572
+ function getAppFeaturePageStubs(pluginJson: PluginJson, featureFlags: Record<string, boolean>): PageStubs {
573
+ const { pages, configPage, skipped } = getAppFeaturePages(pluginJson, featureFlags.hasConfigPage === true);
574
+ const templateDir = join(TEMPLATES_DIR, 'docs', 'app');
575
+ const pageTemplate = join(templateDir, 'feature-page.md');
576
+ const stubs: PageStub[] = [];
577
+ if (configPage) {
578
+ stubs.push({
579
+ templatePath: pageTemplate,
580
+ file: 'configure.md',
581
+ slug: 'configure',
582
+ data: { page: { ...configPage, position: CONFIG_PAGE_POSITION } },
583
+ });
584
+ }
585
+
586
+ const isInFolder = pages.length > MAX_FLAT_PAGES;
587
+ const folder = isInFolder ? 'features/' : '';
588
+ if (isInFolder) {
589
+ stubs.push({
590
+ templatePath: join(templateDir, 'features-index.md'),
591
+ file: 'features/index.md',
592
+ data: { position: FIRST_PAGE_POSITION },
593
+ });
594
+ }
595
+ // plugin-docs-cli compares a folder's index.md with the pages inside it, so these start after it
596
+ const firstPosition = isInFolder ? FIRST_PAGE_POSITION + 1 : FIRST_PAGE_POSITION;
597
+ pages.forEach((page, i) => {
598
+ stubs.push({
599
+ templatePath: pageTemplate,
600
+ file: `${folder}${page.slug}.md`,
601
+ slug: page.slug,
602
+ data: { page: { ...page, position: firstPosition + i } },
603
+ });
604
+ });
605
+
606
+ return {
607
+ data: {
608
+ ...(configPage ? { hasConfigPage: true } : {}),
609
+ ...(pages.length > 0 ? { defaultFeaturePage: { ...pages[0], file: `${folder}${pages[0].slug}` } } : {}),
610
+ },
611
+ stubs,
612
+ skipped,
613
+ };
614
+ }
615
+
272
616
  // mirrors isMetaFile in @grafana/plugin-docs-cli, so both tools agree on what counts as a page
273
617
  const DOCS_CLI_META_FILES = [
274
618
  'README.MD',
@@ -0,0 +1,127 @@
1
+ # Plugin docs
2
+
3
+ This folder is the documentation for **{{{pluginName}}}**, published at
4
+ `grafana.com/grafana/plugins/{{pluginId}}/docs/`.
5
+
6
+ ## Guides
7
+
8
+ Read these before you write or change any page. This docs format is new and your training
9
+ data does not cover it, so do not rely on what you know about other docs tools.
10
+
11
+ Run `{{packageManagerName}} run docs:validate` and open the guide on its last line, `Docs guides:`. It's
12
+ the guides' `index.md`, which says which guide to read for what, and the other guides sit next to it.
13
+ The guides match the version of the validator this plugin uses. The line is a local path, usually
14
+ `node_modules/@grafana/plugin-docs-cli/docs/index.md`, or a URL when the package isn't on disk, such as
15
+ with Yarn Plug'n'Play.
16
+
17
+ The guides name scripts such as `docs:validate`. This plugin uses **{{packageManagerName}}**, so run them
18
+ with `{{packageManagerName}} run`, as in the commands below.
19
+
20
+ ## Pages and their source
21
+
22
+ `configure.md`, the app pages, `dashboards.md` and `permissions.md` exist only when the app has that feature,
23
+ as described under [Optional pages](#optional-pages). An app with up to three pages has one file for each at
24
+ the root. With more, the pages move into a `features/` folder.
25
+
26
+ Files may have moved since the plugin was scaffolded, so trace them from `src/module.ts(x)`.
27
+ `new AppPlugin()` names the root page component in `setRootPage` and each config page component in
28
+ `addConfigPage`. The root page component routes each path in `src/plugin.json` to the component that
29
+ renders it. `src/plugin.json` only lists the pages in the app's navigation, so look for tabs and drilldowns
30
+ in those components too.
31
+
32
+ | Page | Source of truth |
33
+ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `index.md` | `dependencies` in `src/plugin.json`, the setup sections of the README and `provisioning/`. Update the links when pages are added or removed. |
35
+ | `configure.md` | The components passed to `addConfigPage` and the settings type, including `jsonData` and `secureJsonData`, or the in-app page named Configuration when the app has no `addConfigPage`. With a backend, also how it reads them |
36
+ | `features/index.md` | Nothing - it is a curated router for the pages in `features/`, if the folder exists. Update it when pages are added or removed. |
37
+ | App pages | The component the root page routes the page's path to. Screenshots from `info.screenshots` in `src/plugin.json` whose name matches the page |
38
+ | `dashboards.md` | `includes` entries of type `dashboard` in `src/plugin.json` and their JSON files |
39
+ | `permissions.md` | `roles`, `iam` and the `role` and `action` of each `includes` entry in `src/plugin.json` |
40
+ | `troubleshooting.md` | Error and empty-state messages in source, known issues in the README or other docs and reported issues. Never guess failures |
41
+
42
+ ## Optional pages
43
+
44
+ Add one of these pages when the app has the matching feature. Use the filename given here, give the page
45
+ a `sidebar_position` that puts it where it belongs and link it from `index.md`.
46
+
47
+ | Trigger | Page | Scope |
48
+ | ---------------------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
+ | `addConfigPage` in `src/module.ts(x)` | `configure.md` | Opening the config page, every setting and provisioning the app. |
50
+ | An app page named Configuration, when there is no `addConfigPage` | `configure.md` | What the page is for and how to use it, as for any app page. |
51
+ | An `includes` entry of type `page` with a path under `/a/{{pluginId}}` | App page | What the page is for and how to use it. Copy an existing app page, and link it from `index.md`. With a fourth page, move them all into `features/` and list them in `features/index.md`. |
52
+ | An `includes` entry of type `dashboard` | `dashboards.md` | What each bundled dashboard shows and how to import it. |
53
+ | `roles` or `iam` in `src/plugin.json` | `permissions.md` | What each role grants, default assignments and what the app can access. |
54
+
55
+ When an app page grows too long, turn it into a folder of the same name with an `index.md` and
56
+ split it into pages there. Keep every page within three folder levels of this one.
57
+
58
+ ## What not to write
59
+
60
+ Never write installation instructions, such as installing the plugin with `grafana-cli`, from a zip file or
61
+ in Docker. The Installation tab on the plugin page covers them, and these docs start from a plugin that is
62
+ already installed. When the README has an installation section, leave it out of the pages.
63
+
64
+ ## Section briefs
65
+
66
+ Each section of a scaffolded page has a brief telling you what belongs there. A brief is an HTML comment
67
+ that starts with `section-brief`. Search the docs folder for `section-brief` to find what is left to write:
68
+
69
+ ```markdown
70
+ ## Connect your data
71
+
72
+ <!-- section-brief
73
+ Fill this in: The steps to get the data the app shows into Grafana.
74
+
75
+ If the app needs no data of its own, remove this section entirely.
76
+ -->
77
+ ```
78
+
79
+ Some sections also have fixed text, with a brief that builds on it:
80
+
81
+ ```markdown
82
+ ## Open the configuration page
83
+
84
+ To open the {{{pluginName}}} configuration page:
85
+
86
+ 1. Click **Administration** > **Plugins and data** > **Plugins** in the left-side menu.
87
+
88
+ <!-- section-brief
89
+ Fill this in: After the list above, add a sentence that names the tab to click.
90
+ -->
91
+ ```
92
+
93
+ Text outside a brief is written for every app and is part of the scaffold. Keep it, and change it
94
+ only where it's wrong for this plugin, such as a menu path or a step that differs.
95
+
96
+ Everything inside a brief is the instruction. A brief covers the section it sits under, unless it says
97
+ otherwise: it may tell you to extend the fixed text above it, to replace it with several sections or to
98
+ remove the page. When a brief says to remove a section, remove its heading and fixed text too.
99
+
100
+ To fill a section:
101
+
102
+ 1. Read the brief.
103
+ 2. Read the source the page documents, from the table above, then write the section.
104
+ 3. Delete the whole comment, from `<!-- section-brief` to `-->`.
105
+
106
+ Briefs are not shown in the preview or the catalog, so a section you haven't written looks like a bare
107
+ heading. `docs:validate` lists every section that still has only its brief.
108
+
109
+ ## Validate
110
+
111
+ ```bash
112
+ {{packageManagerName}} run docs:validate
113
+ ```
114
+
115
+ Run `docs:validate` after every page you write or change and fix what it reports. A docs task is done when
116
+ it reports no warnings and no suggestions about section briefs. In particular, an `empty-section` or
117
+ `unfilled-section-brief` suggestion means a section is still unwritten or a brief is still in place. Neither
118
+ fails validation, so a passing exit code alone does not mean the docs are done.
119
+
120
+ ## Preview
121
+
122
+ ```bash
123
+ {{packageManagerName}} run docs:serve # local preview at http://localhost:3001, reloads on save
124
+ ```
125
+
126
+ `docs:serve` keeps running until you stop it. Start it in the background if you need to look at the
127
+ rendered pages, and stop it when you are done.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,20 @@
1
+ # {{{pluginName}}} documentation
2
+
3
+ This folder is the documentation for **{{{pluginName}}}**. It's published on the Documentation tab of
4
+ `grafana.com/grafana/plugins/{{pluginId}}/` with each plugin release. This README isn't published.
5
+
6
+ To learn how plugin docs work and how to write them, read the
7
+ [Add documentation to your plugin](https://grafana.com/developers/plugin-tools/how-to-guides/plugin-docs) guide.
8
+
9
+ ## Next steps
10
+
11
+ 1. Write the pages. Each section has a note that says what belongs there. **Or** let an AI agent draft them from your source code and README: commit your changes, then run `{{createPluginCmd}} add docs-content`. Review its work either way.
12
+ 2. Add screenshots, videos and figures where they help readers. Save them inside this folder, for example in `img/`, and link to them with a relative path. `docs:validate` checks their formats and sizes.
13
+ 3. Run `{{packageManagerName}} run docs:serve` to preview the docs. It also lists every section left to fill.
14
+
15
+ ## Commands
16
+
17
+ ```bash
18
+ {{packageManagerName}} run docs:serve # local preview at http://localhost:3001
19
+ {{packageManagerName}} run docs:validate # check your docs, including sections left to fill
20
+ ```