@grafana/create-plugin 7.13.0 → 7.14.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 (39) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/codemods/additions/additions.js +12 -0
  3. package/dist/codemods/additions/prompts/docs-content.md +119 -0
  4. package/dist/codemods/additions/scripts/docs-content.js +30 -0
  5. package/dist/codemods/additions/scripts/docs.js +250 -0
  6. package/dist/codemods/additions/scripts/experimental-app-sdk.js +15 -14
  7. package/dist/codemods/utils.agents.js +64 -0
  8. package/dist/codemods/utils.js +40 -7
  9. package/dist/codemods/utils.workflows.js +94 -0
  10. package/dist/utils/utils.cli.js +3 -2
  11. package/dist/utils/utils.handlebars.js +9 -2
  12. package/package.json +1 -1
  13. package/src/codemods/additions/additions.ts +12 -0
  14. package/src/codemods/additions/prompts/docs-content.md +119 -0
  15. package/src/codemods/additions/scripts/docs-content.test.ts +94 -0
  16. package/src/codemods/additions/scripts/docs-content.ts +32 -0
  17. package/src/codemods/additions/scripts/docs.test.ts +1037 -0
  18. package/src/codemods/additions/scripts/docs.ts +353 -0
  19. package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +4 -3
  20. package/src/codemods/additions/scripts/experimental-app-sdk.ts +15 -15
  21. package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
  22. package/src/codemods/utils.agents.test.ts +140 -0
  23. package/src/codemods/utils.agents.ts +81 -0
  24. package/src/codemods/utils.test.ts +23 -0
  25. package/src/codemods/utils.ts +29 -7
  26. package/src/codemods/utils.workflows.test.ts +153 -0
  27. package/src/codemods/utils.workflows.ts +140 -0
  28. package/src/utils/utils.cli.ts +2 -1
  29. package/src/utils/utils.handlebars.ts +9 -0
  30. package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
  31. package/templates/docs/common/workflows/validate-docs.yml +52 -0
  32. package/templates/docs/panel/docs/AGENTS.md +107 -0
  33. package/templates/docs/panel/docs/CLAUDE.md +1 -0
  34. package/templates/docs/panel/docs/README.md +20 -0
  35. package/templates/docs/panel/docs/data-formats.md +27 -0
  36. package/templates/docs/panel/docs/examples.md +35 -0
  37. package/templates/docs/panel/docs/index.md +26 -0
  38. package/templates/docs/panel/docs/options.md +53 -0
  39. package/templates/docs/panel/docs/troubleshooting.md +35 -0
@@ -196,6 +196,12 @@ describe('utils', () => {
196
196
  const context = new Context(tmpDir);
197
197
  expect(() => readJsonFile(context, 'file.json')).toThrowError();
198
198
  });
199
+
200
+ it.each(['[]', 'null', '"text"'])('should throw when the file contains %s instead of an object', (content) => {
201
+ const context = new Context(tmpDir);
202
+ context.addFile('file.json', content);
203
+ expect(() => readJsonFile(context, 'file.json')).toThrowError('does not contain a JSON object');
204
+ });
199
205
  });
200
206
 
201
207
  describe('getTemplateDataFromContext', () => {
@@ -263,6 +269,23 @@ describe('utils', () => {
263
269
  expect(context.getFile('docs/nested/setup.md')).toBe('pnpm some-name\n');
264
270
  });
265
271
 
272
+ it('skips files that include rejects, matching on the output path relative to the target', () => {
273
+ const context = new Context(tmpDir);
274
+ const folderPath = templateFolder({ 'README.md': 'readme\n', 'nested/page.md.hbs': 'page\n', npmrc: 'x' });
275
+ const seen: string[] = [];
276
+ const added = addTemplateFolder(context, {
277
+ folderPath,
278
+ targetPath: 'docs',
279
+ data,
280
+ include: (file) => {
281
+ seen.push(file);
282
+ return file === 'README.md';
283
+ },
284
+ });
285
+ expect(seen.sort()).toEqual(['.npmrc', 'README.md', 'nested/page.md']);
286
+ expect(added).toEqual(['docs/README.md']);
287
+ });
288
+
266
289
  it('strips .hbs and maps config file names like generate does', () => {
267
290
  const context = new Context(tmpDir);
268
291
  const folderPath = templateFolder({ 'page.md.hbs': 'x', npmrc: 'y' });
@@ -1,4 +1,4 @@
1
- import { dirname, join } from 'node:path';
1
+ import { dirname, join, posix } from 'node:path';
2
2
  import { createRequire } from 'node:module';
3
3
  import { Context } from './context.js';
4
4
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@@ -180,11 +180,16 @@ export function readJsonFile<T extends object = any>(context: Context, path: str
180
180
  if (!context.doesFileExist(path)) {
181
181
  throw new Error(`Cannot find ${path}`);
182
182
  }
183
+ let parsed: unknown;
183
184
  try {
184
- return JSON.parse(context.getFile(path) || '');
185
+ parsed = JSON.parse(context.getFile(path) || '');
185
186
  } catch (e) {
186
187
  throw new Error(`Cannot parse ${path}: ${e}`);
187
188
  }
189
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
190
+ throw new Error(`${path} does not contain a JSON object.`);
191
+ }
192
+ return parsed as T;
188
193
  }
189
194
 
190
195
  export function addDependenciesToPackageJson(
@@ -346,6 +351,8 @@ function sortObjectByKeys<T extends Record<string, any>>(obj: T): T {
346
351
 
347
352
  export const migrationsDebug = debug.extend('migrations');
348
353
  export const additionsDebug = debug.extend('additions');
354
+ // for helpers that migrations and additions share
355
+ export const codemodsDebug = debug.extend('codemods');
349
356
 
350
357
  export function renderTemplate(
351
358
  templatePath: string,
@@ -374,12 +381,23 @@ export function getTemplateDataFromContext(context: Context): TemplateData {
374
381
 
375
382
  /**
376
383
  * Renders every file under `folderPath` into `targetPath`, the way `generate` scaffolds a template folder:
377
- * `.hbs` is stripped and config file names are mapped. Files that already exist are left alone.
384
+ * `.hbs` is stripped and config file names are mapped. Files that already exist are left alone, and so are
385
+ * files `include` rejects, given each file's output path relative to `targetPath` with `/` separators.
378
386
  * Returns the paths it added.
379
387
  */
380
388
  export function addTemplateFolder(
381
389
  context: Context,
382
- { folderPath, targetPath, data }: { folderPath: string; targetPath: string; data: Record<string, unknown> }
390
+ {
391
+ folderPath,
392
+ targetPath,
393
+ data,
394
+ include = () => true,
395
+ }: {
396
+ folderPath: string;
397
+ targetPath: string;
398
+ data: Record<string, unknown>;
399
+ include?: (relativeFile: string) => boolean;
400
+ }
383
401
  ): string[] {
384
402
  if (!existsSync(folderPath)) {
385
403
  throw new Error(
@@ -389,11 +407,15 @@ export function addTemplateFolder(
389
407
 
390
408
  const added: string[] = [];
391
409
  // relative to `cwd`, so glob characters in the install path (such as Windows backslashes) can't break the pattern
392
- for (const relativeFile of glob.sync('**', { cwd: folderPath, dot: true, nodir: true })) {
410
+ for (const relativeFile of glob.sync('**', { cwd: folderPath, dot: true, nodir: true, posix: true })) {
393
411
  const file = join(folderPath, relativeFile);
394
- const filePath = join(targetPath, dirname(relativeFile), getExportFileName(file));
412
+ const outputFile = posix.join(posix.dirname(relativeFile), getExportFileName(file));
413
+ if (!include(outputFile)) {
414
+ continue;
415
+ }
416
+ const filePath = join(targetPath, outputFile);
395
417
  if (context.doesFileExist(filePath)) {
396
- additionsDebug(`${filePath} already exists, skipping`);
418
+ codemodsDebug(`${filePath} already exists, skipping`);
397
419
  continue;
398
420
  }
399
421
  context.addFile(filePath, renderHandlebarsTemplate(readFileSync(file, 'utf-8'), data));
@@ -0,0 +1,153 @@
1
+ import { Context } from './context.js';
2
+ import { bumpActionRefs } from './utils.workflows.js';
3
+
4
+ const WORKFLOW = '.github/workflows/release.yml';
5
+ const BUILD_PLUGIN = 'grafana/plugin-actions/build-plugin';
6
+ const PACKAGE_PLUGIN = 'grafana/plugin-actions/package-plugin';
7
+ const MINIMUMS = { [BUILD_PLUGIN]: 'build-plugin/v1.2.0', [PACKAGE_PLUGIN]: 'package-plugin/v1.3.3' };
8
+
9
+ // a release workflow with one step per `uses` value
10
+ function workflowWith(...uses: string[]): string {
11
+ return `name: Release\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n${uses.map((u) => ` - uses: ${u}\n`).join('')}`;
12
+ }
13
+
14
+ function bump(workflow: string, minimums: Record<string, string> = MINIMUMS) {
15
+ const context = new Context('/virtual');
16
+ context.addFile(WORKFLOW, workflow);
17
+ const result = bumpActionRefs(context, WORKFLOW, minimums);
18
+ return { result, content: context.getFile(WORKFLOW) };
19
+ }
20
+
21
+ describe('bumpActionRefs', () => {
22
+ it.each([
23
+ ['a prefixed tag', 'build-plugin/v1.0.2'],
24
+ ['a bare tag', 'v1.0.0'],
25
+ ])('raises %s to the minimum', (_, ref) => {
26
+ const { content, result } = bump(workflowWith(`${BUILD_PLUGIN}@${ref}`));
27
+ expect(content).toBe(workflowWith(`${BUILD_PLUGIN}@build-plugin/v1.2.0`));
28
+ expect(result).toEqual({ found: [BUILD_PLUGIN], unrecognized: [] });
29
+ });
30
+
31
+ it.each([
32
+ ['a floating minor tag', 'build-plugin/v1.1', 'build-plugin/v1.2'],
33
+ ['a bare floating minor tag', 'v1.0', 'build-plugin/v1.2'],
34
+ ['a floating major tag', 'v0', 'build-plugin/v1'],
35
+ ])('raises %s and keeps it floating', (_, ref, raised) => {
36
+ const { content } = bump(workflowWith(`${BUILD_PLUGIN}@${ref}`));
37
+ expect(content).toBe(workflowWith(`${BUILD_PLUGIN}@${raised}`));
38
+ });
39
+
40
+ it.each(['build-plugin/v1.2.0', 'build-plugin/v2.0.0', 'build-plugin/v1.2', 'build-plugin/v1', 'v1', 'v2', 'main'])(
41
+ 'leaves %s alone',
42
+ (ref) => {
43
+ const workflow = workflowWith(`${BUILD_PLUGIN}@${ref}`);
44
+ const { content, result } = bump(workflow);
45
+ expect(content).toBe(workflow);
46
+ expect(result).toEqual({ found: [BUILD_PLUGIN], unrecognized: [] });
47
+ }
48
+ );
49
+
50
+ it.each([
51
+ ['a branch', 'some-feature-branch'],
52
+ ['a commit SHA', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
53
+ ["another action's tag", 'package-plugin/v1.0.0'],
54
+ ['a tag with a longer prefix', 'foo/build-plugin/v1.0.0'],
55
+ ])('leaves %s alone and reports it', (_, ref) => {
56
+ const workflow = workflowWith(`${BUILD_PLUGIN}@${ref}`);
57
+ const { content, result } = bump(workflow);
58
+ expect(content).toBe(workflow);
59
+ expect(result).toEqual({ found: [BUILD_PLUGIN], unrecognized: [BUILD_PLUGIN] });
60
+ });
61
+
62
+ it('bumps each use independently, to its own action minimum', () => {
63
+ const { content, result } = bump(
64
+ workflowWith(
65
+ `${BUILD_PLUGIN}@v1.0.0`,
66
+ `${BUILD_PLUGIN}@build-plugin/v2.0.0`,
67
+ `${PACKAGE_PLUGIN}@package-plugin/v1.3.2`
68
+ )
69
+ );
70
+ expect(content).toBe(
71
+ workflowWith(
72
+ `${BUILD_PLUGIN}@build-plugin/v1.2.0`,
73
+ `${BUILD_PLUGIN}@build-plugin/v2.0.0`,
74
+ `${PACKAGE_PLUGIN}@package-plugin/v1.3.3`
75
+ )
76
+ );
77
+ expect(result.found).toEqual([BUILD_PLUGIN, PACKAGE_PLUGIN]);
78
+ });
79
+
80
+ it('bumps a quoted ref and keeps the quotes and any trailing comment', () => {
81
+ const { content } = bump(workflowWith(`'${BUILD_PLUGIN}@v1.0.0' # pinned`));
82
+ expect(content).toBe(workflowWith(`'${BUILD_PLUGIN}@build-plugin/v1.2.0' # pinned`));
83
+ });
84
+
85
+ it('only bumps `uses` values, not incidental mentions', () => {
86
+ const mention = `# see ${BUILD_PLUGIN}@v1.0.0 for reference\nenv:\n NOTE: ${BUILD_PLUGIN}@v1.0.0\n`;
87
+ const { content } = bump(`${mention}${workflowWith(`${BUILD_PLUGIN}@v1.0.0`)}`);
88
+ expect(content).toBe(`${mention}${workflowWith(`${BUILD_PLUGIN}@build-plugin/v1.2.0`)}`);
89
+ });
90
+
91
+ it('keeps comments, blank lines and the rest of the workflow unchanged', () => {
92
+ const before = [
93
+ '# release on tag',
94
+ 'name: Release',
95
+ '',
96
+ 'on:',
97
+ ' push:',
98
+ " tags: ['v*']",
99
+ '',
100
+ 'jobs:',
101
+ ' release:',
102
+ ' runs-on: ubuntu-latest',
103
+ ' steps:',
104
+ ' - uses: actions/checkout@v6 # keep me',
105
+ ` - uses: ${BUILD_PLUGIN}@v1.0.0`,
106
+ ' with:',
107
+ ' policy_token: ${{ secrets.TOKEN }}',
108
+ '',
109
+ ].join('\n');
110
+ const { content } = bump(before);
111
+ expect(content).toBe(before.replace(`${BUILD_PLUGIN}@v1.0.0`, `${BUILD_PLUGIN}@build-plugin/v1.2.0`));
112
+ });
113
+
114
+ it('leaves an indented commented-out block exactly as it was', () => {
115
+ const before = workflowWith(
116
+ `${BUILD_PLUGIN}@v1.0.0\n #with:\n # save the token in your repository secrets\n #policy_token: \${{ secrets.TOKEN }}`
117
+ );
118
+ const { content } = bump(before);
119
+ expect(content).toBe(before.replace(`${BUILD_PLUGIN}@v1.0.0`, `${BUILD_PLUGIN}@build-plugin/v1.2.0`));
120
+ });
121
+
122
+ it('leaves a workflow it cannot parse alone', () => {
123
+ const workflow = `jobs: [\n uses: ${BUILD_PLUGIN}@v1.0.0\n`;
124
+ const { content, result } = bump(workflow);
125
+ expect(content).toBe(workflow);
126
+ expect(result).toEqual({ found: [], unrecognized: [] });
127
+ });
128
+
129
+ it('does not match an action whose name only starts with the given one', () => {
130
+ const workflow = workflowWith(`${BUILD_PLUGIN}-extra@v1.0.0`);
131
+ const { content, result } = bump(workflow);
132
+ expect(content).toBe(workflow);
133
+ expect(result.found).toEqual([]);
134
+ });
135
+
136
+ it('reports nothing found when the workflow uses none of the actions', () => {
137
+ const { content, result } = bump('name: Release\n');
138
+ expect(content).toBe('name: Release\n');
139
+ expect(result).toEqual({ found: [], unrecognized: [] });
140
+ });
141
+
142
+ it('reports nothing found when the workflow does not exist', () => {
143
+ const context = new Context('/virtual');
144
+ expect(bumpActionRefs(context, WORKFLOW, MINIMUMS)).toEqual({ found: [], unrecognized: [] });
145
+ expect(context.doesFileExist(WORKFLOW)).toBe(false);
146
+ });
147
+
148
+ it('throws when a minimum is not a version tag', () => {
149
+ expect(() => bump(workflowWith(`${BUILD_PLUGIN}@v1.0.0`), { [BUILD_PLUGIN]: 'main' })).toThrow(
150
+ "The minimum ref 'main' for grafana/plugin-actions/build-plugin is not a version tag."
151
+ );
152
+ });
153
+ });
@@ -0,0 +1,140 @@
1
+ import { isScalar, parseDocument, visit } from 'yaml';
2
+ import type { Context } from './context.js';
3
+ import { codemodsDebug } from './utils.js';
4
+
5
+ export interface BumpActionRefsResult {
6
+ /** The actions used in the workflow. */
7
+ found: string[];
8
+ /** The actions pinned to something other than a version tag, such as a branch or a commit SHA. */
9
+ unrecognized: string[];
10
+ }
11
+
12
+ /**
13
+ * Raises each `uses: <action>@<ref>` in a workflow to at least the given minimum ref, the way
14
+ * addDependenciesToPackageJson raises dependency versions. A ref is never lowered.
15
+ *
16
+ * Refs are compared as `vX[.Y[.Z]]` tags, optionally prefixed with the action's own name as monorepo
17
+ * tags are (`build-plugin/v1.2.0`). A shorter tag floats, so `v1` already runs the latest 1.x and is
18
+ * left alone, and a floating tag that is too low is raised at its own precision (`v1.1` to `v1.2`).
19
+ * Other refs are left as-is and reported, except `main`, which always runs the latest release.
20
+ *
21
+ * The parser finds each `uses` value, then only that value's source text is replaced, so comments and
22
+ * formatting elsewhere stay byte-for-byte the same.
23
+ */
24
+ export function bumpActionRefs(
25
+ context: Context,
26
+ workflowPath: string,
27
+ minimumRefs: Record<string, string>
28
+ ): BumpActionRefsResult {
29
+ const minimums = new Map(
30
+ Object.entries(minimumRefs).map(([action, minimumRef]) => {
31
+ const minimum = parseTag(action, minimumRef);
32
+ if (!minimum) {
33
+ throw new Error(`The minimum ref '${minimumRef}' for ${action} is not a version tag.`);
34
+ }
35
+ return [action, minimum];
36
+ })
37
+ );
38
+
39
+ const content = context.getFile(workflowPath);
40
+ if (!content) {
41
+ codemodsDebug(`Could not find ${workflowPath}. Skipping action bumps.`);
42
+ return { found: [], unrecognized: [] };
43
+ }
44
+
45
+ const doc = parseDocument(content);
46
+ if (doc.errors.length > 0) {
47
+ codemodsDebug(`Failed to parse ${workflowPath}: ${doc.errors[0].message}. Skipping action bumps.`);
48
+ return { found: [], unrecognized: [] };
49
+ }
50
+
51
+ const found = new Set<string>();
52
+ const unrecognized = new Set<string>();
53
+ const edits: Array<{ start: number; end: number; text: string }> = [];
54
+
55
+ visit(doc, {
56
+ Pair: (_, pair) => {
57
+ if (!isScalar(pair.key) || pair.key.value !== 'uses' || !isScalar(pair.value)) {
58
+ return;
59
+ }
60
+ const uses = pair.value;
61
+ if (typeof uses.value !== 'string' || !uses.value.includes('@')) {
62
+ return;
63
+ }
64
+ const separator = uses.value.lastIndexOf('@');
65
+ const action = uses.value.slice(0, separator);
66
+ const ref = uses.value.slice(separator + 1);
67
+ const minimum = minimums.get(action);
68
+ if (!minimum) {
69
+ return;
70
+ }
71
+
72
+ found.add(action);
73
+ if (ref === 'main') {
74
+ return;
75
+ }
76
+ const tag = parseTag(action, ref);
77
+ if (!tag) {
78
+ codemodsDebug(`${workflowPath} pins ${action}@${ref}, which is not a version tag. Leaving it as-is.`);
79
+ unrecognized.add(action);
80
+ return;
81
+ }
82
+ if (meetsMinimum(tag.version, minimum.version)) {
83
+ codemodsDebug(`${workflowPath} already pins ${action}@${ref}. Skipping.`);
84
+ return;
85
+ }
86
+ if (!uses.range) {
87
+ return;
88
+ }
89
+ const [start, end] = uses.range;
90
+ edits.push({ start, end, text: content.slice(start, end).replace(`@${ref}`, `@${raisedRef(tag, minimum)}`) });
91
+ },
92
+ });
93
+
94
+ if (edits.length > 0) {
95
+ // apply from the end so earlier offsets stay valid
96
+ const updated = edits
97
+ .sort((a, b) => b.start - a.start)
98
+ .reduce((acc, { start, end, text }) => `${acc.slice(0, start)}${text}${acc.slice(end)}`, content);
99
+ context.updateFile(workflowPath, updated);
100
+ }
101
+ return { found: [...found], unrecognized: [...unrecognized] };
102
+ }
103
+
104
+ interface Tag {
105
+ /** The text before `v`, such as `build-plugin/`, or empty for a bare tag. */
106
+ prefix: string;
107
+ /** One to three numbers, fewer for a floating tag. */
108
+ version: number[];
109
+ }
110
+
111
+ // reads `vX[.Y[.Z]]`, or `<name>/vX[.Y[.Z]]` where name is the action's last path segment
112
+ function parseTag(action: string, ref: string): Tag | undefined {
113
+ const match = /^(?:([^/]+)\/)?v(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(ref);
114
+ if (!match || (match[1] !== undefined && match[1] !== action.split('/').at(-1))) {
115
+ return undefined;
116
+ }
117
+ return {
118
+ prefix: match[1] === undefined ? '' : `${match[1]}/`,
119
+ version: match
120
+ .slice(2)
121
+ .filter((part) => part !== undefined)
122
+ .map(Number),
123
+ };
124
+ }
125
+
126
+ // compares only the numbers the tag has, since a floating tag runs the latest release below it
127
+ function meetsMinimum(version: number[], minimum: number[]): boolean {
128
+ for (const [i, part] of version.entries()) {
129
+ const minimumPart = minimum[i] ?? 0;
130
+ if (part !== minimumPart) {
131
+ return part > minimumPart;
132
+ }
133
+ }
134
+ return true;
135
+ }
136
+
137
+ function raisedRef(tag: Tag, minimum: Tag): string {
138
+ const version = Array.from({ length: tag.version.length }, (_, i) => minimum.version[i] ?? 0);
139
+ return `${minimum.prefix}v${version.join('.')}`;
140
+ }
@@ -13,6 +13,7 @@ export const argv = minimist(args, {
13
13
  hasBackend: 'backend',
14
14
  pluginName: 'plugin-name',
15
15
  orgName: 'org-name',
16
+ docsPath: 'docs-path',
16
17
  },
17
18
  // tell minimist to parse boolean flags as true/false to prevent minimist from interpreting
18
19
  // them as the command name if passed first. e.g. `create-plugin --force update`
@@ -20,7 +21,7 @@ export const argv = minimist(args, {
20
21
  // --agent is tri-state: absent, bare (pick an agent), or a value (an agent id or a path to a binary).
21
22
  // parsing it as a string keeps bare --agent as '' rather than true, so all three are distinguishable.
22
23
  // --no-agent still arrives as false.
23
- string: ['agent'],
24
+ string: ['agent', 'docsPath'],
24
25
  });
25
26
 
26
27
  export const commandName = argv._[0] || 'generate';
@@ -41,6 +41,14 @@ export const kebabToPascalKebab = (str: string) => {
41
41
  .join('-');
42
42
  };
43
43
 
44
+ // for text inside a double-quoted YAML scalar, e.g. `title: "{{{yamlEscape pluginName}}}"`
45
+ export const yamlEscape = (str: string) => {
46
+ if (typeof str !== 'string') {
47
+ return '';
48
+ }
49
+ return str.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
50
+ };
51
+
44
52
  // Register our helpers and partials with handlebars.
45
53
  registerHandlebarsHelpers();
46
54
  registerHandlebarsPartials();
@@ -63,6 +71,7 @@ function registerHandlebarsHelpers() {
63
71
  properCase: pascalCase,
64
72
  pascalCase: pascalCase,
65
73
  if_eq: ifEq,
74
+ yamlEscape: yamlEscape,
66
75
  };
67
76
 
68
77
  Object.keys(helpers).forEach((helperName) =>
@@ -21,7 +21,7 @@ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs
21
21
  import { join, resolve } from 'node:path';
22
22
  import { arch, platform, tmpdir } from 'node:os';
23
23
 
24
- const VERSION = 'v0.60.3';
24
+ const VERSION = 'v0.60.16';
25
25
  const REPO = 'grafana/grafana-app-sdk';
26
26
  const BIN = 'grafana-app-sdk';
27
27
 
@@ -0,0 +1,52 @@
1
+ name: Validate documentation
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+ paths:
9
+ - '{{docsPath}}/**'
10
+ - 'src/plugin.json'
11
+ - 'package.json'
12
+ {{#if lockFile}}
13
+ - '{{lockFile}}'
14
+ {{/if}}
15
+ pull_request:
16
+ branches:
17
+ - main
18
+ - master
19
+ paths:
20
+ - '{{docsPath}}/**'
21
+ - 'src/plugin.json'
22
+ - 'package.json'
23
+ {{#if lockFile}}
24
+ - '{{lockFile}}'
25
+ {{/if}}
26
+
27
+ jobs:
28
+ validate:
29
+ name: Validate plugin docs
30
+ runs-on: ubuntu-latest
31
+ permissions:
32
+ contents: read
33
+ steps:
34
+ - uses: actions/checkout@v7
35
+ with:
36
+ persist-credentials: false
37
+ {{#if_eq packageManagerName "pnpm"}}
38
+ # pnpm action uses the packageManager field in package.json to
39
+ # understand which version to install.
40
+ - uses: pnpm/action-setup@v6
41
+ {{/if_eq}}
42
+ - name: Setup Node.js environment
43
+ uses: actions/setup-node@v7
44
+ with:
45
+ node-version: '22'
46
+ cache: '{{packageManagerName}}'
47
+
48
+ - name: Install dependencies
49
+ run: {{packageManagerInstallCmd}}
50
+
51
+ - name: Validate plugin documentation
52
+ run: {{packageManagerName}} run docs:validate
@@ -0,0 +1,107 @@
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
+ When you change code, update the page that documents it:
23
+
24
+ | Page | Source of truth |
25
+ | -------------------- | -------------------------------------------------------------------------------------- |
26
+ | `index.md` | Nothing - it is a curated router. Update it when pages are added or removed. |
27
+ | `options.md` | `setPanelOptions` and `useFieldConfig` in `src/module.ts` |
28
+ | `data-formats.md` | The panel component's data handling, plus any field pickers in `src/module.ts` |
29
+ | `examples.md` | `provisioning/dashboards/*.json` if present, otherwise the panel's own option defaults |
30
+ | `troubleshooting.md` | Error and empty-state messages in source, known and reported issues. Never guess. |
31
+
32
+ Never guess failures in `troubleshooting.md`. Each symptom must come from an error, warning or empty-state
33
+ message in source, a known issue in the README or other docs or a reported issue.
34
+
35
+ ## Optional pages
36
+
37
+ Add one of these pages when the plugin has the matching feature. Use the filename given here, give the page
38
+ a `sidebar_position` that puts it where it belongs and link it from `index.md`.
39
+
40
+ | Trigger | Page | Scope |
41
+ | ---------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------ |
42
+ | `setPresetsSupplier(...)` in source | `presets.md` | The presets the panel ships, when each applies, screenshots. |
43
+ | RBAC role declarations in `src/plugin.json` | `permissions.md` | What each role grants, default assignments. |
44
+ | Substantial accessibility considerations (keyboard shortcuts, screen reader notes) | `accessibility.md` | Keyboard interaction, ARIA, contrast, motion preferences. |
45
+
46
+ ## Section briefs
47
+
48
+ Each section of a scaffolded page has a brief telling you what belongs there. A brief is an HTML comment
49
+ that starts with `section-brief`. Search the docs folder for `section-brief` to find what is left to write:
50
+
51
+ ```markdown
52
+ ## Options
53
+
54
+ <!-- section-brief
55
+ Fill this in: Describe every option the plugin registers, one row per option.
56
+
57
+ If the plugin has no options, remove this section entirely.
58
+ -->
59
+ ```
60
+
61
+ Some sections also have fixed text, with a brief that builds on it:
62
+
63
+ ```markdown
64
+ ## Basic example
65
+
66
+ A panel excerpt in dashboard JSON has this shape:
67
+
68
+ <!-- section-brief
69
+ Fill this in: Complete the excerpt above with the options the example needs.
70
+ -->
71
+ ```
72
+
73
+ Text outside a brief is written for every panel and is part of the scaffold. Keep it, and change it
74
+ only where it's wrong for this plugin, such as a menu path or a step that differs.
75
+
76
+ Everything inside a brief is the instruction. A brief covers the section it sits under, unless it says
77
+ otherwise: it may tell you to extend the fixed text above it, to replace it with several sections or to
78
+ remove the page. When a brief says to remove a section, remove its heading and fixed text too.
79
+
80
+ To fill a section:
81
+
82
+ 1. Read the brief.
83
+ 2. Read the source the page documents, from the table above, then write the section.
84
+ 3. Delete the whole comment, from `<!-- section-brief` to `-->`.
85
+
86
+ Briefs are not shown in the preview or the catalog, so a section you haven't written looks like a bare
87
+ heading. `docs:validate` lists every section that still has only its brief.
88
+
89
+ ## Validate
90
+
91
+ ```bash
92
+ {{packageManagerName}} run docs:validate
93
+ ```
94
+
95
+ Run `docs:validate` after every page you write or change and fix what it reports. A docs task is done when
96
+ it reports no warnings and no suggestions about section briefs. In particular, an `empty-section` or
97
+ `unfilled-section-brief` suggestion means a section is still unwritten or a brief is still in place. Neither
98
+ fails validation, so a passing exit code alone does not mean the docs are done.
99
+
100
+ ## Preview
101
+
102
+ ```bash
103
+ {{packageManagerName}} run docs:serve # local preview at http://localhost:3001, reloads on save
104
+ ```
105
+
106
+ `docs:serve` keeps running until you stop it. Start it in the background if you need to look at the
107
+ 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
+ ```
@@ -0,0 +1,27 @@
1
+ ---
2
+ title: Data formats
3
+ description: "Learn what data formats the {{{yamlEscape pluginName}}} panel accepts."
4
+ sidebar_position: 2
5
+ ---
6
+
7
+ This document explains the data the {{{pluginName}}} panel needs from a query, so you can build a matching query or check your test data.
8
+
9
+ ## Supported data shape
10
+
11
+ <!-- section-brief
12
+ Fill this in: Describe the data frame shape {{{pluginName}}} expects: time series with N numeric fields, table with specific columns, single numeric value, geographic coordinates, log lines, etc. Be specific about field types and structure, because this is the contract dashboard authors rely on when building queries for the panel. Name the required field types (time, number, string, boolean) and any minimum/maximum field counts. After each shape, add a `#### Example` table whose headers are the field names the panel reads, with 3-5 rows of realistic values, so a reader can build a matching query or test data.
13
+ -->
14
+
15
+ ## Field mapping
16
+
17
+ <!-- section-brief
18
+ Fill this in: Explain which incoming field plays which role in the visualization - for example "the first time-typed field is the x-axis, all numeric fields become series". If {{{pluginName}}} lets users explicitly pick field roles in the editor, document those controls here. Remove the section if mapping is fully automatic and not user-configurable.
19
+ -->
20
+
21
+ ## Check what your query returns
22
+
23
+ To see the data the panel receives, open the panel editor, click **Query inspector** and select the **Data** tab. You can also turn on **Table view** at the top of the panel editor to see the data as a table.
24
+
25
+ ## Reshape data that doesn't match
26
+
27
+ If a query returns data in a different shape, you can reshape it without changing the query. Select the **Transform data** tab in the panel editor and add a transformation. For more information, refer to [Transform data](https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/transform-data/).