|
| 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