@grafana/create-plugin 7.13.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 +24 -0
- package/dist/codemods/additions/additions.js +12 -0
- package/dist/codemods/additions/prompts/docs-content.md +131 -0
- package/dist/codemods/additions/scripts/docs-content.js +30 -0
- package/dist/codemods/additions/scripts/docs.js +468 -0
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +15 -14
- package/dist/codemods/utils.agents.js +64 -0
- package/dist/codemods/utils.js +40 -7
- package/dist/codemods/utils.workflows.js +94 -0
- package/dist/utils/utils.cli.js +3 -2
- package/dist/utils/utils.handlebars.js +9 -2
- package/package.json +1 -1
- package/src/codemods/additions/additions.ts +12 -0
- package/src/codemods/additions/prompts/docs-content.md +131 -0
- package/src/codemods/additions/scripts/docs-content.test.ts +103 -0
- package/src/codemods/additions/scripts/docs-content.ts +32 -0
- package/src/codemods/additions/scripts/docs.test.ts +1766 -0
- package/src/codemods/additions/scripts/docs.ts +697 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +4 -3
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +15 -15
- package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
- package/src/codemods/utils.agents.test.ts +140 -0
- package/src/codemods/utils.agents.ts +81 -0
- package/src/codemods/utils.test.ts +23 -0
- package/src/codemods/utils.ts +29 -7
- package/src/codemods/utils.workflows.test.ts +153 -0
- package/src/codemods/utils.workflows.ts +140 -0
- package/src/utils/utils.cli.ts +2 -1
- package/src/utils/utils.handlebars.ts +9 -0
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
- 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/common/workflows/validate-docs.yml +52 -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 +113 -0
- package/templates/docs/panel/docs/CLAUDE.md +1 -0
- package/templates/docs/panel/docs/README.md +20 -0
- package/templates/docs/panel/docs/data-formats.md +27 -0
- package/templates/docs/panel/docs/examples.md +35 -0
- package/templates/docs/panel/docs/index.md +26 -0
- package/templates/docs/panel/docs/options.md +53 -0
- package/templates/docs/panel/docs/troubleshooting.md +35 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { basename, join } from 'node:path';
|
|
2
|
+
import * as v from 'valibot';
|
|
3
|
+
import type { Context, ContextMessage } from '../../context.js';
|
|
4
|
+
import { TEMPLATE_PATHS, TEMPLATES_DIR } from '../../../constants.js';
|
|
5
|
+
import { output } from '../../../utils/utils.console.js';
|
|
6
|
+
import {
|
|
7
|
+
additionsDebug,
|
|
8
|
+
addDependenciesToPackageJson,
|
|
9
|
+
addTemplateFolder,
|
|
10
|
+
getTemplateDataFromContext,
|
|
11
|
+
readJsonFile,
|
|
12
|
+
renderTemplate,
|
|
13
|
+
} from '../../utils.js';
|
|
14
|
+
import {
|
|
15
|
+
AGENT_INSTRUCTIONS_PATH,
|
|
16
|
+
appendToAgentInstructions,
|
|
17
|
+
ensureConfigAgentFiles,
|
|
18
|
+
ensureRootAgentFiles,
|
|
19
|
+
} from '../../utils.agents.js';
|
|
20
|
+
import { type BumpActionRefsResult, bumpActionRefs } from '../../utils.workflows.js';
|
|
21
|
+
import { getPackageManagerExecCmd } from '../../../utils/utils.packageManager.js';
|
|
22
|
+
import * as recast from 'recast';
|
|
23
|
+
import * as babelTsParser from 'recast/parsers/babel-ts.js';
|
|
24
|
+
|
|
25
|
+
// an allowlist, because the value becomes a path on disk and is embedded unescaped in a YAML workflow
|
|
26
|
+
const DOCS_PATH_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]|\/[a-zA-Z0-9_-])*$/;
|
|
27
|
+
const DOCS_PATH_RULE =
|
|
28
|
+
'a relative path of letters, digits, hyphens, underscores and single "/" separators (for example "catalog-docs" or "docs/plugin")';
|
|
29
|
+
const DEFAULT_DOCS_PATH = 'catalog-docs';
|
|
30
|
+
|
|
31
|
+
export function isValidDocsPath(value: unknown): value is string {
|
|
32
|
+
return typeof value === 'string' && DOCS_PATH_RE.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertValidDocsPath(value: unknown, command: string, nextStep: string): asserts value is string {
|
|
36
|
+
if (!isValidDocsPath(value)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`src/plugin.json has docsPath set to '${value}', which create-plugin ${command} can't use.\n Change it to ${DOCS_PATH_RULE}, then ${nextStep}.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const schema = v.object({
|
|
44
|
+
docsPath: v.optional(
|
|
45
|
+
v.pipe(
|
|
46
|
+
v.string(),
|
|
47
|
+
v.minLength(1, 'docsPath must not be empty.'),
|
|
48
|
+
v.regex(
|
|
49
|
+
DOCS_PATH_RE,
|
|
50
|
+
`docsPath must be ${DOCS_PATH_RULE}, with no leading "/", "./", trailing "/", ".." segment, backslash or quote character.`
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
type Options = v.InferOutput<typeof schema>;
|
|
57
|
+
|
|
58
|
+
// kept current by a Renovate custom manager in .github/renovate.json, which matches this exact line
|
|
59
|
+
const PLUGIN_DOCS_CLI_VERSION = '^0.8.0';
|
|
60
|
+
|
|
61
|
+
// each type needs a template set under `templates/docs/<type>/`
|
|
62
|
+
const SUPPORTED_PLUGIN_TYPES = ['panel', 'datasource', 'app'] as const;
|
|
63
|
+
type SupportedPluginType = (typeof SUPPORTED_PLUGIN_TYPES)[number];
|
|
64
|
+
|
|
65
|
+
export default function docs(context: Context, options: Options): Context {
|
|
66
|
+
const pluginType = assertSupportedPluginType(context);
|
|
67
|
+
return setupDocsScaffolding({
|
|
68
|
+
context,
|
|
69
|
+
docsPath: options.docsPath,
|
|
70
|
+
// `templates/docs` is left out of TEMPLATE_PATHS so `generate` ignores it
|
|
71
|
+
templateDir: join(TEMPLATES_DIR, 'docs', pluginType),
|
|
72
|
+
commonTemplateDir: join(TEMPLATES_DIR, 'docs', 'common'),
|
|
73
|
+
featurePages: FEATURE_PAGES_BY_TYPE[pluginType],
|
|
74
|
+
pageStubs: pluginType === 'app' ? getAppFeaturePageStubs : undefined,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PluginJson {
|
|
79
|
+
type?: string;
|
|
80
|
+
name?: string;
|
|
81
|
+
id?: string;
|
|
82
|
+
docsPath?: string;
|
|
83
|
+
annotations?: boolean;
|
|
84
|
+
alerting?: boolean;
|
|
85
|
+
includes?: PluginInclude[];
|
|
86
|
+
roles?: unknown;
|
|
87
|
+
iam?: unknown;
|
|
88
|
+
[key: string]: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface PluginInclude {
|
|
92
|
+
type?: string;
|
|
93
|
+
name?: string;
|
|
94
|
+
path?: string;
|
|
95
|
+
role?: string;
|
|
96
|
+
action?: string;
|
|
97
|
+
defaultNav?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DocsSetupOptions {
|
|
101
|
+
context: Context;
|
|
102
|
+
/** Falls back to the docsPath in `src/plugin.json`, then to `catalog-docs`. */
|
|
103
|
+
docsPath?: string;
|
|
104
|
+
/** Templates specific to this plugin type, under `templates/docs/<type>/`. */
|
|
105
|
+
templateDir: string;
|
|
106
|
+
/** Templates shared by every plugin type, under `templates/docs/common/`. */
|
|
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>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
|
|
140
|
+
const { context, templateDir, commonTemplateDir } = opts;
|
|
141
|
+
|
|
142
|
+
const pluginJson = readJsonFile<PluginJson>(context, 'src/plugin.json');
|
|
143
|
+
const existingDocsPath = pluginJson.docsPath;
|
|
144
|
+
|
|
145
|
+
if (existingDocsPath !== undefined) {
|
|
146
|
+
assertValidDocsPath(existingDocsPath, 'add docs', 'run this again');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (opts.docsPath !== undefined && existingDocsPath !== undefined && existingDocsPath !== opts.docsPath) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`src/plugin.json already has docsPath set to '${existingDocsPath}'.\n Re-run with the existing path:\n create-plugin add docs --docsPath ${existingDocsPath}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const docsPath = opts.docsPath ?? existingDocsPath ?? DEFAULT_DOCS_PATH;
|
|
156
|
+
|
|
157
|
+
// a folder plugin.json doesn't declare isn't ours. doesFileExistOnDisk also catches an empty directory
|
|
158
|
+
if (existingDocsPath === undefined && context.doesFileExistOnDisk(docsPath)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`A directory already exists at '${docsPath}' but src/plugin.json has no docsPath.\n Point the plugin at it by setting "docsPath": "${docsPath}" in src/plugin.json, or scaffold elsewhere:\n create-plugin add docs --docsPath <alternative-path>`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!pluginJson.name) {
|
|
165
|
+
throw new Error('src/plugin.json has no "name". Add one - it is used as the title throughout the docs.');
|
|
166
|
+
}
|
|
167
|
+
if (!pluginJson.id) {
|
|
168
|
+
throw new Error('src/plugin.json has no "id". Add one - it is used in the catalog links throughout the docs.');
|
|
169
|
+
}
|
|
170
|
+
const pluginId = pluginJson.id;
|
|
171
|
+
|
|
172
|
+
// only rewritten when docsPath changes, so re-runs don't reformat plugin.json
|
|
173
|
+
if (existingDocsPath !== docsPath) {
|
|
174
|
+
context.updateFile('src/plugin.json', JSON.stringify({ ...pluginJson, docsPath }, null, 2));
|
|
175
|
+
}
|
|
176
|
+
|
|
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: [] };
|
|
185
|
+
const { packageManagerName, packageManagerVersion } = templateData;
|
|
186
|
+
const createPluginCmd = `${getPackageManagerExecCmd(packageManagerName, packageManagerVersion)}@latest`;
|
|
187
|
+
const data = {
|
|
188
|
+
...templateData,
|
|
189
|
+
pluginId,
|
|
190
|
+
docsPath,
|
|
191
|
+
createPluginCmd,
|
|
192
|
+
lockFile: LOCK_FILES[packageManagerName],
|
|
193
|
+
...featureFlags,
|
|
194
|
+
...pageStubs.data,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
addDependenciesToPackageJson(context, {}, { '@grafana/plugin-docs-cli': PLUGIN_DOCS_CLI_VERSION });
|
|
198
|
+
|
|
199
|
+
addDocsScripts(context);
|
|
200
|
+
|
|
201
|
+
// pages are only rendered into a folder without any, so pages the author deleted stay deleted
|
|
202
|
+
const hasPages = hasDocsPages(context, docsPath);
|
|
203
|
+
const disabledPageFiles = featurePages.filter(({ isEnabled }) => !isEnabled).map(({ page }) => `${page}.md`);
|
|
204
|
+
addTemplateFolder(context, {
|
|
205
|
+
folderPath: join(templateDir, 'docs'),
|
|
206
|
+
targetPath: docsPath,
|
|
207
|
+
data,
|
|
208
|
+
include: (file) => SCAFFOLD_META_FILES.includes(file) || (!hasPages && !disabledPageFiles.includes(file)),
|
|
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
|
+
};
|
|
221
|
+
|
|
222
|
+
// left alone when it exists, since the author may have customized it
|
|
223
|
+
const workflowPath = '.github/workflows/validate-docs.yml';
|
|
224
|
+
if (!context.doesFileExist(workflowPath)) {
|
|
225
|
+
context.addFile(workflowPath, renderTemplate(join(commonTemplateDir, 'workflows/validate-docs.yml'), false, data));
|
|
226
|
+
} else {
|
|
227
|
+
additionsDebug(`${workflowPath} already exists, skipping`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const pluginActions = bumpActionRefs(context, RELEASE_WORKFLOW_PATH, REQUIRED_ACTION_REFS);
|
|
231
|
+
|
|
232
|
+
// a plugin that predates the root and .config/AGENTS files gets them here
|
|
233
|
+
const linkedRootFiles = ensureRootAgentFiles(context, TEMPLATE_PATHS.common);
|
|
234
|
+
ensureConfigAgentFiles(context, TEMPLATE_PATHS.common, CONFIG_AGENT_FILES, data);
|
|
235
|
+
appendDocsPointerToInstructions(context, docsPath);
|
|
236
|
+
|
|
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) {
|
|
239
|
+
context.setMessage(
|
|
240
|
+
buildNextStepsMessage({ docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles, report })
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return context;
|
|
245
|
+
}
|
|
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
|
+
|
|
299
|
+
function buildNextStepsMessage(opts: {
|
|
300
|
+
docsPath: string;
|
|
301
|
+
packageManagerName: string;
|
|
302
|
+
createPluginCmd: string;
|
|
303
|
+
pluginActions: BumpActionRefsResult;
|
|
304
|
+
linkedRootFiles: string[];
|
|
305
|
+
report: PageReport;
|
|
306
|
+
}): ContextMessage {
|
|
307
|
+
const { docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles } = opts;
|
|
308
|
+
const { missingFeaturePages, missingPageStubs, skippedPages } = opts.report;
|
|
309
|
+
const run = (script: string) => output.formatCode(`${packageManagerName} run ${script}`);
|
|
310
|
+
|
|
311
|
+
const steps: NextStep[] = [
|
|
312
|
+
{
|
|
313
|
+
title: `Read ${docsPath}/README.md`,
|
|
314
|
+
detail: 'It has the commands and a link to the plugin docs guide.',
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
title: 'Write the pages, yourself or with an AI agent',
|
|
318
|
+
detail: [
|
|
319
|
+
`Fill in or review the pages under ${docsPath}/ by hand.`,
|
|
320
|
+
'Or commit these changes, then have an agent draft them from your source code and README:',
|
|
321
|
+
output.formatCode(`${createPluginCmd} add docs-content`),
|
|
322
|
+
'Review its work either way.',
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
title: 'Preview the pages',
|
|
327
|
+
detail: `Run ${run('docs:serve')}, which also lists every section left to fill.`,
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
const warnings: NextStep[] = [];
|
|
332
|
+
if (pluginActions.found.length === 0) {
|
|
333
|
+
warnings.push({
|
|
334
|
+
title: `No build-plugin or package-plugin step found in ${RELEASE_WORKFLOW_PATH}`,
|
|
335
|
+
detail: `Make sure your release pipeline builds ${docsPath}/ into the plugin archive, or the docs won't publish.`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
for (const action of pluginActions.unrecognized) {
|
|
339
|
+
warnings.push({
|
|
340
|
+
title: `${RELEASE_WORKFLOW_PATH} pins ${action} to a branch or commit, so it was left as is`,
|
|
341
|
+
detail: `Docs need ${REQUIRED_ACTION_REFS[action]} or later.`,
|
|
342
|
+
});
|
|
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
|
+
}
|
|
362
|
+
for (const file of linkedRootFiles) {
|
|
363
|
+
warnings.push({
|
|
364
|
+
title: `Added a pointer to ${AGENT_INSTRUCTIONS_PATH} at the end of ${file}`,
|
|
365
|
+
detail: 'Agents that read it now also load the plugin instructions. Check it fits with the rest of the file.',
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const body = steps.flatMap((step, i) => formatNextStep(step, ` ${i + 1}. `));
|
|
370
|
+
if (warnings.length > 0) {
|
|
371
|
+
body.push('', 'Needs your attention:', ...warnings.flatMap((warning) => formatNextStep(warning, ' ! ')));
|
|
372
|
+
}
|
|
373
|
+
return { level: 'log', title: 'Next steps', body };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
interface NextStep {
|
|
377
|
+
title: string;
|
|
378
|
+
detail?: string | string[];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function formatNextStep({ title, detail }: NextStep, marker: string): string[] {
|
|
382
|
+
const indent = ' '.repeat(marker.length);
|
|
383
|
+
return [`${marker}${title}`, ...[detail ?? []].flat().map((line) => `${indent}${line}`)];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function assertSupportedPluginType(context: Context, additionName = 'docs'): SupportedPluginType {
|
|
387
|
+
const { type } = readJsonFile<PluginJson>(context, 'src/plugin.json');
|
|
388
|
+
|
|
389
|
+
if (isSupportedPluginType(type)) {
|
|
390
|
+
return type;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const supported = SUPPORTED_PLUGIN_TYPES.join(', ');
|
|
394
|
+
throw new Error(
|
|
395
|
+
`create-plugin add ${additionName} needs a plugin type of ${supported} in src/plugin.json, but found '${type ?? 'unset'}'.`
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function isSupportedPluginType(type: string | undefined): type is SupportedPluginType {
|
|
400
|
+
return SUPPORTED_PLUGIN_TYPES.includes(type as SupportedPluginType);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// instructions.md imports e2e-testing.md, so the two are created together
|
|
404
|
+
const CONFIG_AGENT_FILES = ['instructions.md', 'e2e-testing.md'];
|
|
405
|
+
// the scaffold's files that aren't pages, so they're added on every run
|
|
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
|
+
|
|
616
|
+
// mirrors isMetaFile in @grafana/plugin-docs-cli, so both tools agree on what counts as a page
|
|
617
|
+
const DOCS_CLI_META_FILES = [
|
|
618
|
+
'README.MD',
|
|
619
|
+
'CONTRIBUTING.MD',
|
|
620
|
+
'LICENSE.MD',
|
|
621
|
+
'CODE_OF_CONDUCT.MD',
|
|
622
|
+
'SECURITY.MD',
|
|
623
|
+
'CHANGELOG.MD',
|
|
624
|
+
'AGENTS.MD',
|
|
625
|
+
'CLAUDE.MD',
|
|
626
|
+
];
|
|
627
|
+
|
|
628
|
+
export function hasDocsPages(context: Context, docsPath: string): boolean {
|
|
629
|
+
const addedInContext = Object.entries(context.listChanges())
|
|
630
|
+
.filter(([path, change]) => change.changeType === 'add' && path.startsWith(`${docsPath}/`))
|
|
631
|
+
.map(([path]) => path);
|
|
632
|
+
return [...listFiles(context, docsPath), ...addedInContext].some(
|
|
633
|
+
(file) =>
|
|
634
|
+
file.endsWith('.md') &&
|
|
635
|
+
!DOCS_CLI_META_FILES.includes(basename(file).toUpperCase()) &&
|
|
636
|
+
!isInSkippedFolder(file.slice(docsPath.length + 1))
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// plugin-docs-cli skips these too, and walking node_modules would be slow
|
|
641
|
+
const SKIPPED_FOLDERS = ['node_modules', 'dist'];
|
|
642
|
+
|
|
643
|
+
function isInSkippedFolder(relativePath: string): boolean {
|
|
644
|
+
return relativePath.split('/').some((segment) => SKIPPED_FOLDERS.includes(segment));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// readDir gives nothing for a file, so this walks into every entry
|
|
648
|
+
function listFiles(context: Context, folder: string): string[] {
|
|
649
|
+
return context
|
|
650
|
+
.readDir(folder)
|
|
651
|
+
.flatMap((entry) => (SKIPPED_FOLDERS.includes(basename(entry)) ? [] : [entry, ...listFiles(context, entry)]));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function appendDocsPointerToInstructions(context: Context, docsPath: string): void {
|
|
655
|
+
const agentsPath = `${docsPath}/AGENTS.md`;
|
|
656
|
+
// a link rather than an @-import, so the docs guidance only loads when an agent works in the docs folder
|
|
657
|
+
const section = `## Plugin docs\n\nWhenever you add, remove or change a feature in the plugin, you must update the plugin documentation under \`${docsPath}/\`. Refer to [\`${agentsPath}\`](../../${agentsPath}) for instructions.\n`;
|
|
658
|
+
appendToAgentInstructions(context, agentsPath, section);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// so the docs workflow also runs when the docs CLI is bumped
|
|
662
|
+
const LOCK_FILES: Record<string, string> = {
|
|
663
|
+
npm: 'package-lock.json',
|
|
664
|
+
pnpm: 'pnpm-lock.yaml',
|
|
665
|
+
yarn: 'yarn.lock',
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const RELEASE_WORKFLOW_PATH = '.github/workflows/release.yml';
|
|
669
|
+
// the oldest release of each action that builds docs into the plugin archive
|
|
670
|
+
const REQUIRED_ACTION_REFS: Record<string, string> = {
|
|
671
|
+
'grafana/plugin-actions/build-plugin': 'build-plugin/v1.2.0',
|
|
672
|
+
'grafana/plugin-actions/package-plugin': 'package-plugin/v1.3.3',
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const DOCS_SCRIPTS: Record<string, string> = {
|
|
676
|
+
'docs:serve': 'plugin-docs-cli serve --port 3001 --reload',
|
|
677
|
+
'docs:validate': 'plugin-docs-cli validate',
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
function addDocsScripts(context: Context): void {
|
|
681
|
+
const packageJson = readJsonFile<Record<string, unknown>>(context, 'package.json');
|
|
682
|
+
const scripts = (packageJson['scripts'] ?? {}) as Record<string, string>;
|
|
683
|
+
const missing = Object.entries(DOCS_SCRIPTS).filter(([name]) => {
|
|
684
|
+
if (scripts[name]) {
|
|
685
|
+
additionsDebug(`${name} already exists in package.json scripts, skipping`);
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
return true;
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
if (missing.length > 0) {
|
|
692
|
+
context.updateFile(
|
|
693
|
+
'package.json',
|
|
694
|
+
JSON.stringify({ ...packageJson, scripts: { ...scripts, ...Object.fromEntries(missing) } }, null, 2)
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
}
|