@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.
- package/CHANGELOG.md +8 -0
- package/dist/codemods/additions/prompts/docs-content.md +24 -12
- package/dist/codemods/additions/scripts/docs.js +229 -11
- package/package.json +1 -1
- package/src/codemods/additions/prompts/docs-content.md +24 -12
- package/src/codemods/additions/scripts/docs-content.test.ts +11 -2
- package/src/codemods/additions/scripts/docs.test.ts +793 -64
- package/src/codemods/additions/scripts/docs.ts +354 -10
- package/templates/docs/app/docs/AGENTS.md +127 -0
- package/templates/docs/app/docs/CLAUDE.md +1 -0
- package/templates/docs/app/docs/README.md +20 -0
- package/templates/docs/app/docs/configure.md +65 -0
- package/templates/docs/app/docs/dashboards.md +29 -0
- package/templates/docs/app/docs/index.md +70 -0
- package/templates/docs/app/docs/permissions.md +45 -0
- package/templates/docs/app/docs/troubleshooting.md +65 -0
- package/templates/docs/app/feature-page.md +26 -0
- package/templates/docs/app/features-index.md +12 -0
- package/templates/docs/datasource/docs/AGENTS.md +123 -0
- package/templates/docs/datasource/docs/CLAUDE.md +1 -0
- package/templates/docs/datasource/docs/README.md +20 -0
- package/templates/docs/datasource/docs/alerting.md +56 -0
- package/templates/docs/datasource/docs/annotations.md +68 -0
- package/templates/docs/datasource/docs/configure.md +114 -0
- package/templates/docs/datasource/docs/dashboards.md +28 -0
- package/templates/docs/datasource/docs/index.md +40 -0
- package/templates/docs/datasource/docs/query-editor.md +72 -0
- package/templates/docs/datasource/docs/template-variables.md +69 -0
- package/templates/docs/datasource/docs/troubleshooting.md +73 -0
- 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
|
|
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
|
|
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
|
+
```
|