Skip to content

Commit e4d428c

Browse files
committed
feat(create-plugin): add panel-docs codemod with AI authoring assistance
1 parent ca746b2 commit e4d428c

20 files changed

Lines changed: 1384 additions & 772 deletions

File tree

packages/create-plugin/src/codemods/additions/additions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ export default [
1414
{
1515
name: 'panel-docs',
1616
description: 'Scaffolds multi-page documentation for a Grafana panel plugin',
17-
scriptPath: import.meta.resolve('./scripts/panel-docs/index.js'),
17+
scriptPath: import.meta.resolve('./scripts/panel-docs.js'),
1818
},
1919
] satisfies Codemod[];

packages/create-plugin/src/codemods/additions/scripts/panel-docs.test.ts

Lines changed: 655 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { glob } from 'glob';
4+
import * as v from 'valibot';
5+
import type { Context } from '../../context.js';
6+
import { TEMPLATES_DIR } from '../../../constants.js';
7+
import { output } from '../../../utils/utils.console.js';
8+
import { isFile } from '../../../utils/utils.files.js';
9+
import { additionsDebug, addDependenciesToPackageJson, isVersionGreater, readJsonFile } from '../../utils.js';
10+
11+
export const schema = v.object({
12+
docsPath: v.optional(
13+
v.pipe(
14+
v.string(),
15+
v.minLength(1, 'docsPath must not be empty.'),
16+
v.check(
17+
(value) => !value.startsWith('/') && !value.split('/').includes('..'),
18+
'docsPath must be a relative path without ".." segments.'
19+
)
20+
),
21+
'docs'
22+
),
23+
});
24+
25+
type Options = v.InferOutput<typeof schema>;
26+
27+
export default function panelDocs(context: Context, options: Options): Context {
28+
assertPluginType(context, { expectedType: 'panel', codemodName: 'panel-docs' });
29+
return setupDocsScaffolding({
30+
context,
31+
docsPath: options.docsPath,
32+
// docs templates are split by plugin type so a future datasource-docs codemod reuses
33+
// `docs/common/`. `templates/docs` is deliberately absent from TEMPLATE_PATHS, so `generate`
34+
// ignores it until we scaffold docs for every new plugin.
35+
templateDir: join(TEMPLATES_DIR, 'docs', 'panel'),
36+
commonTemplateDir: join(TEMPLATES_DIR, 'docs', 'common'),
37+
codemodName: 'panel-docs',
38+
});
39+
}
40+
41+
const REQUIRED_BUILD_PLUGIN_REF = 'build-plugin/v1.2.0';
42+
43+
interface PluginJson {
44+
type?: string;
45+
name?: string;
46+
docsPath?: string;
47+
[key: string]: unknown;
48+
}
49+
50+
export interface DocsSetupOptions {
51+
context: Context;
52+
docsPath: string;
53+
/** Templates specific to this plugin type, under `templates/docs/<type>/`. */
54+
templateDir: string;
55+
/** Templates shared by every plugin type, under `templates/docs/common/`. */
56+
commonTemplateDir: string;
57+
codemodName: string;
58+
}
59+
60+
export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
61+
const { context, docsPath, templateDir, commonTemplateDir, codemodName } = opts;
62+
63+
// step 1: early exit if the docs directory already exists on disk
64+
if (existsSync(join(context.basePath, docsPath))) {
65+
throw new Error(
66+
`A directory already exists at '${docsPath}'. Re-run with a different path:\n create-plugin add ${codemodName} --docsPath <alternative-path>`
67+
);
68+
}
69+
70+
// step 2: set docsPath in src/plugin.json
71+
const pluginJson = readPluginJson(context);
72+
73+
const existingDocsPath = pluginJson.docsPath;
74+
if (existingDocsPath !== undefined && existingDocsPath !== docsPath) {
75+
throw new Error(
76+
`src/plugin.json already has docsPath set to '${existingDocsPath}'.\n Re-run with the existing path:\n create-plugin add ${codemodName} --docsPath ${existingDocsPath}`
77+
);
78+
}
79+
context.updateFile('src/plugin.json', JSON.stringify({ ...pluginJson, docsPath }, null, 2));
80+
81+
const pluginName = pluginJson.name ?? 'my-plugin';
82+
83+
// step 3: add @grafana/plugin-docs-cli as a devDependency
84+
addDependenciesToPackageJson(context, {}, { '@grafana/plugin-docs-cli': '^0.2.1' });
85+
86+
// step 4: add docs:serve and docs:validate npm scripts
87+
addDocsScripts(context);
88+
89+
// step 5: copy template files to docs folder (includes README.md)
90+
copyDocsTemplates(context, templateDir, docsPath, pluginName);
91+
92+
// append the AI-workflow section to the docs README
93+
appendAgentSuffixToReadme(context, templateDir, docsPath, pluginName);
94+
95+
// step 6: copy validate-docs workflow, unless the user already customized one
96+
const workflowPath = '.github/workflows/validate-docs.yml';
97+
if (!context.doesFileExist(workflowPath)) {
98+
const workflowContent = readTemplate(commonTemplateDir, 'workflows/validate-docs.yml').replaceAll(
99+
'{{docsPath}}',
100+
docsPath
101+
);
102+
context.addFile(workflowPath, workflowContent);
103+
} else {
104+
additionsDebug(`${workflowPath} already exists, skipping`);
105+
}
106+
107+
// step 7: bump build-plugin version in release.yml
108+
bumpBuildPluginVersion(context);
109+
110+
// step 8: scaffold the docs authoring guide and bootstrap skill. `agentAssistanceAdded` is false
111+
// on a re-run where every agent file already exists, which changes the next-steps wording.
112+
const agentAssistanceAdded = copyAgentTemplates(context, templateDir, pluginName, docsPath);
113+
const instructionsPointerAdded = agentAssistanceAdded && appendDocsPointerToInstructions(context, docsPath);
114+
115+
// step 9: print next-steps summary
116+
const readmePresent = existsSync(join(context.basePath, 'README.md'));
117+
printNextSteps({ docsPath, agentAssistanceAdded, instructionsPointerAdded, readmePresent });
118+
119+
return context;
120+
}
121+
122+
function printNextSteps(opts: {
123+
docsPath: string;
124+
agentAssistanceAdded: boolean;
125+
instructionsPointerAdded: boolean;
126+
readmePresent: boolean;
127+
}): void {
128+
const { docsPath, agentAssistanceAdded, instructionsPointerAdded, readmePresent } = opts;
129+
const body: string[] = [];
130+
if (agentAssistanceAdded) {
131+
const readmeMention = readmePresent ? ' (and mine your README for content)' : '';
132+
body.push(`Run the \`/bootstrap-plugin-docs\` skill to draft docs for your current features${readmeMention}`);
133+
body.push(
134+
'Authoring conventions live in .config/AGENTS/plugin-docs.md - your coding agent reads them automatically'
135+
);
136+
if (!instructionsPointerAdded) {
137+
body.push(
138+
'No .config/AGENTS/instructions.md found, so nothing points at .config/AGENTS/plugin-docs.md - reference it from your own agent instructions'
139+
);
140+
}
141+
} else {
142+
body.push(`Fill in the stub docs under ${docsPath}/ with your plugin's actual content`);
143+
}
144+
body.push('Run `npm run docs:serve` to preview the docs locally');
145+
body.push('Run `npm run docs:validate` to check for issues before pushing');
146+
output.log({ title: 'Next steps', body });
147+
}
148+
149+
// `readJsonFile` covers the missing-file and unparseable cases. it does not check the shape, so
150+
// guard against the JSON values that parse fine but are not a usable plugin.json (`null`, an array,
151+
// a bare string).
152+
function readPluginJson(context: Context): PluginJson {
153+
const parsed = readJsonFile<PluginJson>(context, 'src/plugin.json');
154+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
155+
throw new Error('src/plugin.json does not contain a JSON object.');
156+
}
157+
return parsed;
158+
}
159+
160+
// verifies plugin.json's `type` matches the expected value. Throws a helpful
161+
// error otherwise.
162+
export function assertPluginType(
163+
context: Context,
164+
opts: { expectedType: 'datasource' | 'panel'; codemodName: string }
165+
): PluginJson {
166+
const parsed = readPluginJson(context);
167+
if (parsed.type !== opts.expectedType) {
168+
const otherCommand = opts.expectedType === 'datasource' ? 'panel-docs' : 'datasource-docs';
169+
throw new Error(
170+
`create-plugin add ${opts.codemodName} only works on '${opts.expectedType}' plugins, but this plugin's type is '${parsed.type ?? 'unset'}'. Try create-plugin add ${otherCommand} if this is the other plugin type.`
171+
);
172+
}
173+
return parsed;
174+
}
175+
176+
function copyDocsTemplates(context: Context, templateDir: string, docsPath: string, pluginName: string): void {
177+
const docsTemplateDir = join(templateDir, 'docs');
178+
if (!existsSync(docsTemplateDir)) {
179+
throw new Error(
180+
`Cannot find docs templates at ${docsTemplateDir}. This is a bug in @grafana/create-plugin - please report it.`
181+
);
182+
}
183+
for (const filePath of glob.sync(`${docsTemplateDir}/**`, { dot: true }).filter(isFile)) {
184+
const relativePath = filePath.slice(docsTemplateDir.length + 1);
185+
const targetPath = `${docsPath}/${relativePath}`;
186+
if (!context.doesFileExist(targetPath)) {
187+
const content = readFileSync(filePath, 'utf-8').replaceAll('{{pluginName}}', pluginName);
188+
context.addFile(targetPath, content);
189+
} else {
190+
additionsDebug(`${targetPath} already exists, skipping`);
191+
}
192+
}
193+
}
194+
195+
// Skills have to be written once per agent, because no agent reads another's directory and
196+
// SKILL.md supports no include mechanism - Claude Code ignores the `@import` syntax that works in
197+
// CLAUDE.md, and a SKILL.md whose first line isn't `---` is treated as literal content rather than
198+
// parsed for frontmatter. So each destination gets a complete, self-contained copy.
199+
//
200+
// `.agents/skills/` is the cross-agent path (Codex, Cursor, Copilot, Gemini CLI and Amp all read
201+
// it), which is why there is no separate `.codex/skills/` copy.
202+
const DOCS_INSTRUCTIONS_MARKER = 'before writing or modifying plugin documentation';
203+
const SKILLS_TEMPLATE_PREFIX = 'skills/';
204+
const SKILL_TARGET_DIRS = ['.claude/skills', '.agents/skills'];
205+
206+
// The `agent/` template subtree mirrors its destination, except `skills/` which fans out to every
207+
// agent's skills directory:
208+
// agent/.config/AGENTS/plugin-docs.md -> .config/AGENTS/plugin-docs.md
209+
// agent/skills/<name>/SKILL.md -> .claude/skills/<name>/SKILL.md
210+
// -> .agents/skills/<name>/SKILL.md
211+
function copyAgentTemplates(context: Context, templateDir: string, pluginName: string, docsPath: string): boolean {
212+
const agentTemplateDir = join(templateDir, 'agent');
213+
if (!existsSync(agentTemplateDir)) {
214+
throw new Error(
215+
`Cannot find agent templates at ${agentTemplateDir}. This is a bug in @grafana/create-plugin - please report it.`
216+
);
217+
}
218+
let wroteSomething = false;
219+
for (const filePath of glob.sync(`${agentTemplateDir}/**`, { dot: true }).filter(isFile)) {
220+
const relPath = filePath.slice(agentTemplateDir.length + 1);
221+
const content = readFileSync(filePath, 'utf-8')
222+
.replaceAll('{{pluginName}}', pluginName)
223+
.replaceAll('{{docsPath}}', docsPath);
224+
const targetPaths = relPath.startsWith(SKILLS_TEMPLATE_PREFIX)
225+
? SKILL_TARGET_DIRS.map((dir) => `${dir}/${relPath.slice(SKILLS_TEMPLATE_PREFIX.length)}`)
226+
: [relPath];
227+
228+
for (const targetPath of targetPaths) {
229+
if (context.doesFileExist(targetPath)) {
230+
additionsDebug(`${targetPath} already exists, skipping`);
231+
continue;
232+
}
233+
context.addFile(targetPath, content);
234+
wroteSomething = true;
235+
}
236+
}
237+
return wroteSomething;
238+
}
239+
240+
// points the plugin's existing agent instructions at the docs authoring guide. No-op when the
241+
// plugin predates the `.config/AGENTS/` convention.
242+
function appendDocsPointerToInstructions(context: Context, docsPath: string): boolean {
243+
const targetPath = '.config/AGENTS/instructions.md';
244+
const existing = context.getFile(targetPath);
245+
if (existing === undefined) {
246+
additionsDebug(`${targetPath} not found; skipping docs guide pointer`);
247+
return false;
248+
}
249+
if (existing.includes(DOCS_INSTRUCTIONS_MARKER)) {
250+
additionsDebug(`${targetPath} already points at the docs guide, skipping`);
251+
return true;
252+
}
253+
const trailingNewline = existing.endsWith('\n') ? '' : '\n';
254+
const line = `- This plugin ships multi-page docs under \`${docsPath}/\`. Keep them in sync when features change in \`src/\`. Read @./.config/AGENTS/plugin-docs.md ${DOCS_INSTRUCTIONS_MARKER}.\n`;
255+
context.updateFile(targetPath, `${existing}${trailingNewline}${line}`);
256+
return true;
257+
}
258+
259+
// appends the AI-workflow suffix to the docs README. No-op if the README is missing from Context
260+
// or if the suffix is already present.
261+
function appendAgentSuffixToReadme(context: Context, templateDir: string, docsPath: string, pluginName: string): void {
262+
const readmePath = `${docsPath}/README.md`;
263+
const existing = context.getFile(readmePath);
264+
if (existing === undefined) {
265+
additionsDebug(`${readmePath} not found in context; skipping agent-workflow suffix`);
266+
return;
267+
}
268+
if (existing.includes('AI authoring assistance')) {
269+
additionsDebug(`${readmePath} already contains the AI authoring section, skipping`);
270+
return;
271+
}
272+
const suffix = readTemplate(templateDir, 'README-suffix.md').replaceAll('{{pluginName}}', pluginName);
273+
const trailingNewline = existing.endsWith('\n') ? '' : '\n';
274+
context.updateFile(readmePath, `${existing}${trailingNewline}${suffix}`);
275+
}
276+
277+
function readTemplate(templateDir: string, relativePath: string): string {
278+
return readFileSync(join(templateDir, relativePath), 'utf-8');
279+
}
280+
281+
// matches an anchored `uses: grafana/plugin-actions/build-plugin@<ref>` line,
282+
// capturing the prefix (for reassembly) and the existing ref (to compare
283+
// versions before overwriting it).
284+
const BUILD_PLUGIN_USES_RE = /(uses:\s*grafana\/plugin-actions\/build-plugin@)([^\s'"]+)/g;
285+
// matches the version out of either a bare `vX.Y.Z` tag or a `build-plugin/vX.Y.Z`
286+
// tag - the two ref shapes this codemod and its templates actually use.
287+
const BUILD_PLUGIN_TAG_RE = /^(?:build-plugin\/)?v(\d+\.\d+\.\d+)$/;
288+
289+
function bumpBuildPluginVersion(context: Context): void {
290+
const releaseYmlContent = context.getFile('.github/workflows/release.yml');
291+
if (!releaseYmlContent) {
292+
additionsDebug('no .github/workflows/release.yml found, skipping build-plugin version bump');
293+
return;
294+
}
295+
296+
let matched = false;
297+
const requiredVersion = BUILD_PLUGIN_TAG_RE.exec(REQUIRED_BUILD_PLUGIN_REF)?.[1];
298+
const updated = releaseYmlContent.replace(BUILD_PLUGIN_USES_RE, (fullMatch, prefix: string, existingRef: string) => {
299+
matched = true;
300+
const existingVersion = BUILD_PLUGIN_TAG_RE.exec(existingRef)?.[1];
301+
// only skip the bump when both refs parse as versions and the existing one
302+
// is already at least as new - an unparseable ref (a branch, a SHA) always
303+
// gets normalized to the required tag.
304+
if (existingVersion && requiredVersion && !isVersionGreater(requiredVersion, existingVersion, false)) {
305+
additionsDebug(
306+
`release.yml already pins build-plugin@${existingRef}, which is not older than ${REQUIRED_BUILD_PLUGIN_REF}, skipping`
307+
);
308+
return fullMatch;
309+
}
310+
return `${prefix}${REQUIRED_BUILD_PLUGIN_REF}`;
311+
});
312+
313+
if (!matched) {
314+
additionsDebug('no grafana/plugin-actions/build-plugin reference found in release.yml, skipping');
315+
return;
316+
}
317+
if (updated === releaseYmlContent) {
318+
additionsDebug('release.yml build-plugin reference(s) already up to date, skipping');
319+
return;
320+
}
321+
context.updateFile('.github/workflows/release.yml', updated);
322+
}
323+
324+
function addDocsScripts(context: Context): void {
325+
const packageJson = readJsonFile<Record<string, unknown>>(context, 'package.json');
326+
const scripts = (packageJson['scripts'] ?? {}) as Record<string, string>;
327+
let changed = false;
328+
329+
if (!scripts['docs:serve']) {
330+
scripts['docs:serve'] = 'plugin-docs-cli serve --port 3001 --reload';
331+
changed = true;
332+
} else {
333+
additionsDebug('docs:serve already exists in package.json scripts, skipping');
334+
}
335+
336+
if (!scripts['docs:validate']) {
337+
scripts['docs:validate'] = 'plugin-docs-cli validate --strict';
338+
changed = true;
339+
} else {
340+
additionsDebug('docs:validate already exists in package.json scripts, skipping');
341+
}
342+
343+
if (changed) {
344+
context.updateFile('package.json', JSON.stringify({ ...packageJson, scripts }, null, 2));
345+
}
346+
}

0 commit comments

Comments
 (0)