From d96b0c0c4294a634482b0b9cddafd9f9b2d21f76 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 19 May 2026 17:25:49 +0200 Subject: [PATCH 01/13] scaffold catalog-docs codemod stub and register it --- .../create-plugin/src/codemods/additions/additions.ts | 5 +++++ .../src/codemods/additions/scripts/catalog-docs/index.ts | 8 ++++++++ .../src/codemods/additions/scripts/catalog-docs/schema.ts | 7 +++++++ 3 files changed, 20 insertions(+) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/schema.ts diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index e7787fcf2a..6a14b3edd8 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -11,4 +11,9 @@ export default [ description: 'Externalizes the react JSX runtime to help migrate plugins to React 19', scriptPath: import.meta.resolve('./scripts/externalize-jsx-runtime.js'), }, + { + name: 'catalog-docs', + description: 'Enables multi-page docs for the Grafana Plugin Catalog', + scriptPath: import.meta.resolve('./scripts/catalog-docs/index.js'), + }, ] satisfies Codemod[]; diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts new file mode 100644 index 0000000000..69bab04574 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts @@ -0,0 +1,8 @@ +import type { Context } from '../../../context.js'; +import { type CatalogDocsOptions, schema } from './schema.js'; + +export { schema }; + +export default function catalogDocs(context: Context, _options: CatalogDocsOptions): Context { + return context; +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/schema.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/schema.ts new file mode 100644 index 0000000000..8772ca6e10 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/schema.ts @@ -0,0 +1,7 @@ +import * as v from 'valibot'; + +export const schema = v.object({ + docsPath: v.optional(v.string(), 'docs'), +}); + +export type CatalogDocsOptions = v.InferOutput; From 1bd294788e251f5859f3ee196fe7ab4caac49065 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 19 May 2026 17:28:44 +0200 Subject: [PATCH 02/13] add catalog-docs template files --- .../templates/app/docs/configuration.md | 11 +++++ .../templates/common/docs/index.md | 13 ++++++ .../datasource/docs/configuration.md | 13 ++++++ .../templates/datasource/docs/query-editor.md | 13 ++++++ .../templates/panel/docs/options.md | 13 ++++++ .../templates/workflows/validate-docs.yml | 42 +++++++++++++++++++ 6 files changed, 105 insertions(+) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md new file mode 100644 index 0000000000..b21174218d --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md @@ -0,0 +1,11 @@ +--- +title: Configuration +description: Learn how to configure the {{pluginName}} app. +sidebar_position: 2 +--- + +# Configuration + +Describe the configuration options for the {{pluginName}} app plugin. + +## App settings diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md new file mode 100644 index 0000000000..6a9b61a78e --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md @@ -0,0 +1,13 @@ +--- +title: Overview +description: Learn about the {{pluginName}} plugin. +sidebar_position: 1 +--- + +# {{pluginName}} + +Describe what this plugin does and what problem it solves. + +## Features + +## Requirements diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md new file mode 100644 index 0000000000..6af634d240 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md @@ -0,0 +1,13 @@ +--- +title: Configuration +description: Learn how to configure the {{pluginName}} data source. +sidebar_position: 3 +--- + +# Configuration + +Describe how to configure a new {{pluginName}} data source instance. + +## Connection settings + +## Authentication diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md new file mode 100644 index 0000000000..871ebf2155 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md @@ -0,0 +1,13 @@ +--- +title: Query editor +description: Learn how to use the query editor with the {{pluginName}} data source. +sidebar_position: 2 +--- + +# Query editor + +Describe how to build queries using the query editor. + +## Query types + +## Options and filters diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md new file mode 100644 index 0000000000..8dc42f9bf5 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md @@ -0,0 +1,13 @@ +--- +title: Panel options +description: Reference for {{pluginName}} panel options. +sidebar_position: 2 +--- + +# Panel options + +List and describe the options available in the panel editor. + +## Standard options + +## Custom options diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml new file mode 100644 index 0000000000..bbed7485f8 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml @@ -0,0 +1,42 @@ +name: Validate documentation + +on: + push: + branches: + - main + - master + paths: + - 'docs/**' + - 'src/plugin.json' + pull_request: + branches: + - main + - master + paths: + - 'docs/**' + - 'src/plugin.json' + +jobs: + validate: + name: Validate plugin docs + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js environment + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Validate plugin documentation + run: | + DOCS_PATH=$(jq -r '.docsPath // empty' src/plugin.json) + if [ -z "$DOCS_PATH" ]; then + echo "docsPath not set in src/plugin.json, skipping" + exit 0 + fi + npx --yes @grafana/plugin-docs-cli validate --strict From 9d956915a5fc79a65f5e2e2187d3409af3a4a879 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 19 May 2026 19:49:50 +0200 Subject: [PATCH 03/13] implement catalog-docs codemod with tests Adds the full `create-plugin add catalog-docs` command: - reads src/plugin.json to detect plugin type and set docsPath - adds @grafana/plugin-docs-cli devDependency and docs:serve/docs:validate scripts - copies type-specific markdown stubs into the docs folder with pluginName interpolation - copies validate-docs.yml CI workflow (always overwrites) - bumps build-plugin action ref in release.yml - idempotent: each step skips gracefully if already applied --- .../scripts/catalog-docs/index.test.ts | 209 ++++++++++++++++++ .../additions/scripts/catalog-docs/index.ts | 149 ++++++++++++- .../templates/app/docs/configuration.md | 2 + 3 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts new file mode 100644 index 0000000000..7b126c5f54 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts @@ -0,0 +1,209 @@ +import { existsSync } from 'node:fs'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Context } from '../../../context.js'; +import catalogDocs from './index.js'; + +// capture the real existsSync before mocking so we can restore it in beforeEach +const { existsSync: realExistsSync } = await vi.importActual('node:fs'); + +vi.mock('node:fs', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + existsSync: vi.fn().mockImplementation(mod.existsSync), + }; +}); + +function makeContext(pluginType = 'datasource'): Context { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: pluginType, name: 'My Plugin' })); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); + return context; +} + +describe('catalog-docs codemod', () => { + beforeEach(() => { + vi.mocked(existsSync).mockImplementation(realExistsSync); + }); + + describe('early exit', () => { + it('throws if docs directory already exists on disk', () => { + vi.mocked(existsSync).mockReturnValueOnce(true); + const context = makeContext(); + expect(() => catalogDocs(context, { docsPath: 'docs' })).toThrow("A directory already exists at 'docs'"); + }); + }); + + describe('plugin.json step', () => { + it('adds docsPath to src/plugin.json', () => { + const context = makeContext(); + catalogDocs(context, { docsPath: 'docs' }); + const pluginJson = JSON.parse(context.getFile('src/plugin.json') ?? '{}'); + expect(pluginJson.docsPath).toBe('docs'); + }); + + it('throws if src/plugin.json is missing', () => { + const context = new Context('/virtual'); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + expect(() => catalogDocs(context, { docsPath: 'docs' })).toThrow('Cannot find src/plugin.json'); + }); + + it('skips docsPath update when already set to a different value', () => { + const context = new Context('/virtual'); + context.addFile( + 'src/plugin.json', + JSON.stringify({ type: 'datasource', name: 'My Plugin', docsPath: 'custom-docs' }) + ); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); + catalogDocs(context, { docsPath: 'docs' }); + const pluginJson = JSON.parse(context.getFile('src/plugin.json') ?? '{}'); + expect(pluginJson.docsPath).toBe('custom-docs'); // unchanged + }); + }); + + describe('devDependency and scripts step', () => { + it('adds @grafana/plugin-docs-cli to devDependencies', () => { + const context = makeContext(); + catalogDocs(context, { docsPath: 'docs' }); + const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); + expect(pkg.devDependencies?.['@grafana/plugin-docs-cli']).toBe('^0.0.10'); + }); + + it('adds docs:serve and docs:validate scripts', () => { + const context = makeContext(); + catalogDocs(context, { docsPath: 'docs' }); + const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); + expect(pkg.scripts?.['docs:serve']).toBe('plugin-docs-cli serve --port 3001 --reload'); + expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate --strict'); + }); + + it('skips docs:serve if already present', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); + context.addFile( + 'package.json', + JSON.stringify({ scripts: { 'docs:serve': 'custom-command' }, devDependencies: {} }) + ); + context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); + catalogDocs(context, { docsPath: 'docs' }); + const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); + expect(pkg.scripts?.['docs:serve']).toBe('custom-command'); // unchanged + expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate --strict'); // new + }); + }); + + describe('docs folder creation step', () => { + it('creates docs/index.md for all plugin types', () => { + const context = makeContext('datasource'); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/index.md')).toBe(true); + }); + + it('creates type-specific docs files for datasource', () => { + const context = makeContext('datasource'); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/query-editor.md')).toBe(true); + expect(context.doesFileExist('docs/configuration.md')).toBe(true); + }); + + it('creates type-specific docs files for panel', () => { + const context = makeContext('panel'); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/options.md')).toBe(true); + }); + + it('creates type-specific docs files for app', () => { + const context = makeContext('app'); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/configuration.md')).toBe(true); + }); + + it('uses app templates for scenesapp plugin type', () => { + const context = makeContext('scenesapp'); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/configuration.md')).toBe(true); + }); + + it('interpolates pluginName in template content', () => { + const context = makeContext('datasource'); + catalogDocs(context, { docsPath: 'docs' }); + const content = context.getFile('docs/index.md') ?? ''; + expect(content).toContain('My Plugin'); + expect(content).not.toContain('{{pluginName}}'); + }); + + it('skips existing files in the context', () => { + const context = makeContext('datasource'); + context.addFile('docs/index.md', '# Existing content'); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.getFile('docs/index.md')).toBe('# Existing content'); + }); + + it('uses a custom docsPath when specified', () => { + const context = makeContext('datasource'); + catalogDocs(context, { docsPath: 'my-docs' }); + expect(context.doesFileExist('my-docs/index.md')).toBe(true); + }); + }); + + describe('validate-docs workflow step', () => { + it('creates .github/workflows/validate-docs.yml', () => { + const context = makeContext(); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true); + }); + + it('overwrites existing .github/workflows/validate-docs.yml', () => { + const context = makeContext(); + context.addFile('.github/workflows/validate-docs.yml', 'old content'); + catalogDocs(context, { docsPath: 'docs' }); + const content = context.getFile('.github/workflows/validate-docs.yml') ?? ''; + expect(content).not.toBe('old content'); + expect(content).toContain('plugin-docs-cli validate --strict'); + }); + }); + + describe('release.yml build-plugin bump step', () => { + it('bumps build-plugin ref in release.yml', () => { + const context = makeContext(); + catalogDocs(context, { docsPath: 'docs' }); + const content = context.getFile('.github/workflows/release.yml') ?? ''; + expect(content).toContain('grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step'); + }); + + it('handles multiple build-plugin refs in release.yml', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + context.addFile( + '.github/workflows/release.yml', + 'uses: grafana/plugin-actions/build-plugin@v1.0.0\nuses: grafana/plugin-actions/build-plugin@v2.0.0\n' + ); + catalogDocs(context, { docsPath: 'docs' }); + const content = context.getFile('.github/workflows/release.yml') ?? ''; + expect(content).toBe( + 'uses: grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step\nuses: grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step\n' + ); + }); + + it('skips gracefully when release.yml has no build-plugin reference', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + context.addFile('.github/workflows/release.yml', 'name: Release\n'); + catalogDocs(context, { docsPath: 'docs' }); + const content = context.getFile('.github/workflows/release.yml') ?? ''; + expect(content).toBe('name: Release\n'); // unchanged + }); + + it('skips gracefully when release.yml does not exist', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + // no release.yml + expect(() => catalogDocs(context, { docsPath: 'docs' })).not.toThrow(); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts index 69bab04574..0518934287 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts @@ -1,8 +1,155 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { Context } from '../../../context.js'; +import { additionsDebug, addDependenciesToPackageJson } from '../../../utils.js'; import { type CatalogDocsOptions, schema } from './schema.js'; export { schema }; -export default function catalogDocs(context: Context, _options: CatalogDocsOptions): Context { +// TODO: replace with stable tag once plugin-actions PR #219 merges +const REQUIRED_BUILD_PLUGIN_REF = 'eriksundell/plugin-docs-build-step'; + +interface PluginJson { + type?: string; + name?: string; + docsPath?: string; + [key: string]: unknown; +} + +export default function catalogDocs(context: Context, options: CatalogDocsOptions): Context { + const { docsPath } = options; + + // step 1: early exit if the docs directory already exists on disk + if (existsSync(join(context.basePath, docsPath))) { + throw new Error( + `A directory already exists at '${docsPath}'. Re-run with a different path:\n create-plugin add catalog-docs --docsPath ` + ); + } + + // step 2: set docsPath in src/plugin.json + const pluginJsonContent = context.getFile('src/plugin.json'); + if (pluginJsonContent === undefined) { + throw new Error('Cannot find src/plugin.json. Run this command from the plugin root directory.'); + } + + let pluginJson: PluginJson; + try { + pluginJson = JSON.parse(pluginJsonContent); + } catch (e) { + throw new Error(`Cannot parse src/plugin.json: ${e}`); + } + + const existingDocsPath = pluginJson.docsPath; + if (existingDocsPath !== undefined && existingDocsPath !== docsPath) { + additionsDebug(`src/plugin.json already has docsPath set to '${existingDocsPath}', skipping update`); + } else { + context.updateFile('src/plugin.json', JSON.stringify({ ...pluginJson, docsPath }, null, 2)); + } + + // extract type and name for use in later steps + const pluginType = pluginJson.type ?? 'app'; + const pluginName = pluginJson.name ?? 'my-plugin'; + + // step 3: add @grafana/plugin-docs-cli as a devDependency + addDependenciesToPackageJson(context, {}, { '@grafana/plugin-docs-cli': '^0.0.10' }); + + // step 4: add docs:serve and docs:validate npm scripts + addDocsScripts(context); + + // step 5: copy template files to docs folder + copyDocsTemplates(context, pluginType, docsPath, pluginName); + + // step 6: copy validate-docs workflow + upsertFile(context, '.github/workflows/validate-docs.yml', readTemplateFile('workflows/validate-docs.yml')); + + // step 7: bump build-plugin version in release.yml + bumpBuildPluginVersion(context); + return context; } + +function copyDocsTemplates(context: Context, pluginType: string, docsPath: string, pluginName: string): void { + const templateFolderType = pluginType === 'scenesapp' ? 'app' : pluginType; + for (const typeFolder of ['common', templateFolderType]) { + const templateDir = fileURLToPath(new URL(`./templates/${typeFolder}/docs`, import.meta.url)); + if (!existsSync(templateDir)) { + continue; + } + for (const filePath of listFilesRecursively(templateDir)) { + const relativePath = filePath.slice(templateDir.length + 1); + const targetPath = `${docsPath}/${relativePath}`; + if (!context.doesFileExist(targetPath)) { + const content = readFileSync(filePath, 'utf-8').replaceAll('{{pluginName}}', pluginName); + context.addFile(targetPath, content); + } else { + additionsDebug(`${targetPath} already exists, skipping`); + } + } + } +} + +function listFilesRecursively(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = join(dir, entry.name); + return entry.isDirectory() ? listFilesRecursively(fullPath) : [fullPath]; + }); +} + +function upsertFile(context: Context, path: string, content: string): void { + if (context.doesFileExist(path)) { + context.updateFile(path, content); + } else { + context.addFile(path, content); + } +} + +function readTemplateFile(relativePath: string): string { + const templatePath = fileURLToPath(new URL(`./templates/${relativePath}`, import.meta.url)); + return readFileSync(templatePath, 'utf-8'); +} + +function bumpBuildPluginVersion(context: Context): void { + const releaseYmlContent = context.getFile('.github/workflows/release.yml'); + if (!releaseYmlContent) { + additionsDebug('no .github/workflows/release.yml found, skipping build-plugin version bump'); + return; + } + const updated = releaseYmlContent.replace( + /(grafana\/plugin-actions\/build-plugin@)[^\s'"]+/g, + `$1${REQUIRED_BUILD_PLUGIN_REF}` + ); + if (updated === releaseYmlContent) { + additionsDebug('no grafana/plugin-actions/build-plugin reference found in release.yml, skipping'); + return; + } + context.updateFile('.github/workflows/release.yml', updated); +} + +function addDocsScripts(context: Context): void { + const packageJsonContent = context.getFile('package.json'); + if (!packageJsonContent) { + return; + } + const packageJson = JSON.parse(packageJsonContent) as Record; + const scripts = (packageJson['scripts'] ?? {}) as Record; + let changed = false; + + if (!scripts['docs:serve']) { + scripts['docs:serve'] = 'plugin-docs-cli serve --port 3001 --reload'; + changed = true; + } else { + additionsDebug('docs:serve already exists in package.json scripts, skipping'); + } + + if (!scripts['docs:validate']) { + scripts['docs:validate'] = 'plugin-docs-cli validate --strict'; + changed = true; + } else { + additionsDebug('docs:validate already exists in package.json scripts, skipping'); + } + + if (changed) { + context.updateFile('package.json', JSON.stringify({ ...packageJson, scripts }, null, 2)); + } +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md index b21174218d..72c3c7d344 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md @@ -9,3 +9,5 @@ sidebar_position: 2 Describe the configuration options for the {{pluginName}} app plugin. ## App settings + +## Provisioning From 25b97ceba44020e3d481d5c10df2ad7c496beaec Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 19 May 2026 20:07:47 +0200 Subject: [PATCH 04/13] cleanup --- .../src/codemods/additions/scripts/catalog-docs/index.ts | 8 ++++++-- .../src/codemods/additions/scripts/catalog-docs/schema.ts | 7 ------- 2 files changed, 6 insertions(+), 9 deletions(-) delete mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/schema.ts diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts index 0518934287..36b28a1f13 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts @@ -1,11 +1,15 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import * as v from 'valibot'; import type { Context } from '../../../context.js'; import { additionsDebug, addDependenciesToPackageJson } from '../../../utils.js'; -import { type CatalogDocsOptions, schema } from './schema.js'; -export { schema }; +export const schema = v.object({ + docsPath: v.optional(v.string(), 'docs'), +}); + +type CatalogDocsOptions = v.InferOutput; // TODO: replace with stable tag once plugin-actions PR #219 merges const REQUIRED_BUILD_PLUGIN_REF = 'eriksundell/plugin-docs-build-step'; diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/schema.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/schema.ts deleted file mode 100644 index 8772ca6e10..0000000000 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as v from 'valibot'; - -export const schema = v.object({ - docsPath: v.optional(v.string(), 'docs'), -}); - -export type CatalogDocsOptions = v.InferOutput; From 0996ba0b5cc2c1b9b4b781039587fb3a320f1b4c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 19 May 2026 20:44:40 +0200 Subject: [PATCH 05/13] Refine catalog-docs codemod after review - Move schema inline to index.ts, remove separate schema.ts - Remove h1 headings from all doc templates (frontmatter title is authoritative) - Fix workflow action versions to v4 (checkout, setup-node) - Update rollup.config.ts to glob nested codemod entry points and copy all templates dirs generically - Use runtime readFileSync approach for template loading (revert esbuild text loader approach) - Restore realExistsSync mock pattern in tests so template dir checks hit the real filesystem --- .../additions/scripts/catalog-docs/index.test.ts | 2 +- .../codemods/additions/scripts/catalog-docs/index.ts | 8 ++++++++ .../catalog-docs/templates/app/docs/configuration.md | 2 -- .../catalog-docs/templates/common/docs/index.md | 2 -- .../templates/datasource/docs/configuration.md | 2 -- .../templates/datasource/docs/query-editor.md | 2 -- .../catalog-docs/templates/panel/docs/options.md | 2 -- .../templates/workflows/validate-docs.yml | 4 ++-- rollup.config.ts | 12 +++++++++++- 9 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts index 7b126c5f54..2ef57e8790 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Context } from '../../../context.js'; import catalogDocs from './index.js'; -// capture the real existsSync before mocking so we can restore it in beforeEach +// capture the real existsSync before mocking so we can delegate to it in beforeEach const { existsSync: realExistsSync } = await vi.importActual('node:fs'); vi.mock('node:fs', async (importOriginal) => { diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts index 36b28a1f13..24a1f635b2 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts @@ -70,6 +70,14 @@ export default function catalogDocs(context: Context, options: CatalogDocsOption // step 7: bump build-plugin version in release.yml bumpBuildPluginVersion(context); + // step 8: print next-steps summary + console.log(` +Next steps: + - Fill in the stub docs under ${docsPath}/ with your plugin's actual content + - Run \`npm run docs:serve\` to preview the docs locally + - Run \`npm run docs:validate\` to check for issues before pushing +`); + return context; } diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md index 72c3c7d344..f0c1643199 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md @@ -4,8 +4,6 @@ description: Learn how to configure the {{pluginName}} app. sidebar_position: 2 --- -# Configuration - Describe the configuration options for the {{pluginName}} app plugin. ## App settings diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md index 6a9b61a78e..c7d1190e5b 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md @@ -4,8 +4,6 @@ description: Learn about the {{pluginName}} plugin. sidebar_position: 1 --- -# {{pluginName}} - Describe what this plugin does and what problem it solves. ## Features diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md index 6af634d240..9d7907a433 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md @@ -4,8 +4,6 @@ description: Learn how to configure the {{pluginName}} data source. sidebar_position: 3 --- -# Configuration - Describe how to configure a new {{pluginName}} data source instance. ## Connection settings diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md index 871ebf2155..b5c63b4098 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md @@ -4,8 +4,6 @@ description: Learn how to use the query editor with the {{pluginName}} data sour sidebar_position: 2 --- -# Query editor - Describe how to build queries using the query editor. ## Query types diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md index 8dc42f9bf5..8eb212b15e 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md @@ -4,8 +4,6 @@ description: Reference for {{pluginName}} panel options. sidebar_position: 2 --- -# Panel options - List and describe the options available in the panel editor. ## Standard options diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml index bbed7485f8..d8bdcaa599 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml @@ -23,12 +23,12 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Setup Node.js environment - uses: actions/setup-node@v6 + uses: actions/setup-node@v4 with: node-version: '24' diff --git a/rollup.config.ts b/rollup.config.ts index a78aec578a..dab102b10b 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -39,7 +39,7 @@ if (pkg.name === '@grafana/create-plugin') { ignore: ['**/*.test.ts'], absolute: true, }; - const codeMods = glob.sync('{migrations,additions}/scripts/*.ts', codeModsGlobOptions).map((m) => m.toString()); + const codeMods = glob.sync('{migrations,additions}/scripts/**/*.ts', codeModsGlobOptions).map((m) => m.toString()); input.push(...codeMods); external.push('prettier'); @@ -142,6 +142,16 @@ function copyAssets(): Plugin { const distStyles = join(projectRoot, 'dist', 'server', 'styles'); await cp(srcStyles, distStyles, { recursive: true }); } + + if (pkg.name === '@grafana/create-plugin') { + const templateDirs = glob.sync('src/codemods/**/templates', { cwd: projectRoot, absolute: true }); + await Promise.all( + templateDirs.map((src) => { + const dist = src.replace(`${projectRoot}/src/`, `${projectRoot}/dist/`); + return cp(src, dist, { recursive: true }); + }) + ); + } }, }; } From 377cc6546bb4f6bc434ad054daae1a0188c180d1 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sun, 24 May 2026 20:43:38 +0200 Subject: [PATCH 06/13] add ds specific templates --- .../scripts/catalog-docs/index.test.ts | 174 +++++++++++++++++- .../additions/scripts/catalog-docs/index.ts | 74 +++++++- .../templates/datasource/docs/alerting.md | 35 ++++ .../templates/datasource/docs/annotations.md | 35 ++++ .../datasource/docs/configuration.md | 40 +++- .../templates/datasource/docs/query-editor.md | 24 ++- .../datasource/docs/template-variables.md | 35 ++++ .../datasource/docs/troubleshooting.md | 19 ++ 8 files changed, 420 insertions(+), 16 deletions(-) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/alerting.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/annotations.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/template-variables.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/troubleshooting.md diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts index 2ef57e8790..f09ec02137 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts @@ -1,5 +1,7 @@ -import { existsSync } from 'node:fs'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Context } from '../../../context.js'; import catalogDocs from './index.js'; @@ -14,19 +16,53 @@ vi.mock('node:fs', async (importOriginal) => { }; }); -function makeContext(pluginType = 'datasource'): Context { - const context = new Context('/virtual'); - context.addFile('src/plugin.json', JSON.stringify({ type: pluginType, name: 'My Plugin' })); +interface MakeContextOptions { + pluginType?: string; + pluginJsonExtras?: Record; + basePath?: string; +} + +function makeContext(pluginTypeOrOpts: string | MakeContextOptions = 'datasource'): Context { + const opts: MakeContextOptions = + typeof pluginTypeOrOpts === 'string' ? { pluginType: pluginTypeOrOpts } : pluginTypeOrOpts; + const { pluginType = 'datasource', pluginJsonExtras = {}, basePath = '/virtual' } = opts; + const context = new Context(basePath); + context.addFile('src/plugin.json', JSON.stringify({ type: pluginType, name: 'My Plugin', ...pluginJsonExtras })); context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); return context; } describe('catalog-docs codemod', () => { + const tempDirs: string[] = []; + + function makeTempPluginDir(srcFiles: Record = {}): string { + const dir = mkdtempSync(join(tmpdir(), 'catalog-docs-test-')); + tempDirs.push(dir); + if (Object.keys(srcFiles).length > 0) { + mkdirSync(join(dir, 'src'), { recursive: true }); + for (const [relPath, content] of Object.entries(srcFiles)) { + const target = join(dir, 'src', relPath); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, content); + } + } + return dir; + } + beforeEach(() => { vi.mocked(existsSync).mockImplementation(realExistsSync); }); + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + describe('early exit', () => { it('throws if docs directory already exists on disk', () => { vi.mocked(existsSync).mockReturnValueOnce(true); @@ -146,6 +182,134 @@ describe('catalog-docs codemod', () => { catalogDocs(context, { docsPath: 'my-docs' }); expect(context.doesFileExist('my-docs/index.md')).toBe(true); }); + + it('generates the standard datasource H2 headings in configuration.md', () => { + const context = makeContext('datasource'); + catalogDocs(context, { docsPath: 'docs' }); + const content = context.getFile('docs/configuration.md') ?? ''; + expect(content).toContain('## Before you begin'); + expect(content).toContain('## Configure the data source'); + expect(content).toContain('## Configuration options'); + expect(content).toContain('## Provision the data source'); + }); + + it('generates troubleshooting.md as its own page', () => { + const context = makeContext('datasource'); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/troubleshooting.md')).toBe(true); + const content = context.getFile('docs/troubleshooting.md') ?? ''; + expect(content).toContain('## Common issues'); + }); + + it('wraps each section in agent-hint blocks', () => { + const context = makeContext('datasource'); + catalogDocs(context, { docsPath: 'docs' }); + const content = context.getFile('docs/configuration.md') ?? ''; + expect(content).toContain(''); + expect(content).toContain(''); + }); + }); + + describe('conditional template files', () => { + describe('template-variables.md', () => { + it.each([ + ['metricFindQuery', 'export function metricFindQuery(q: string) { return []; }\n'], + [ + 'CustomVariableSupport', + 'import { CustomVariableSupport } from "@grafana/data"; class V extends CustomVariableSupport {}\n', + ], + [ + 'StandardVariableSupport', + 'import { StandardVariableSupport } from "@grafana/data"; class V extends StandardVariableSupport {}\n', + ], + [ + 'DataSourceVariableSupport', + 'import { DataSourceVariableSupport } from "@grafana/data"; class V extends DataSourceVariableSupport {}\n', + ], + ])('is generated when src contains %s', (_, source) => { + const basePath = makeTempPluginDir({ 'datasource.ts': source }); + const context = makeContext({ basePath }); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/template-variables.md')).toBe(true); + const content = context.getFile('docs/template-variables.md') ?? ''; + expect(content).toContain('## Use query variables'); + }); + + it('finds variable-support tokens in nested directories', () => { + const basePath = makeTempPluginDir({ + 'nested/deep/queries.ts': 'export const x = (ds: any) => ds.metricFindQuery("foo");\n', + }); + const context = makeContext({ basePath }); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/template-variables.md')).toBe(true); + }); + + it('is skipped when no variable-support token is found', () => { + const basePath = makeTempPluginDir({ + 'datasource.ts': 'export function query(q: string) { return []; }\n', + }); + const context = makeContext({ basePath }); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/template-variables.md')).toBe(false); + }); + + it('is skipped when src directory does not exist', () => { + const basePath = makeTempPluginDir(); + const context = makeContext({ basePath }); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/template-variables.md')).toBe(false); + }); + }); + + describe('annotations.md', () => { + it('is generated when plugin.json sets annotations: true', () => { + const context = makeContext({ pluginJsonExtras: { annotations: true } }); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/annotations.md')).toBe(true); + const content = context.getFile('docs/annotations.md') ?? ''; + expect(content).toContain('## Create an annotation query'); + }); + + it('is skipped when plugin.json omits annotations', () => { + const context = makeContext(); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/annotations.md')).toBe(false); + }); + + it('is skipped when plugin.json sets annotations: false', () => { + const context = makeContext({ pluginJsonExtras: { annotations: false } }); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/annotations.md')).toBe(false); + }); + }); + + describe('alerting.md', () => { + it('is generated when both alerting and backend are true', () => { + const context = makeContext({ pluginJsonExtras: { alerting: true, backend: true } }); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/alerting.md')).toBe(true); + const content = context.getFile('docs/alerting.md') ?? ''; + expect(content).toContain('## Create an alert rule'); + }); + + it('is skipped when only alerting is true', () => { + const context = makeContext({ pluginJsonExtras: { alerting: true } }); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/alerting.md')).toBe(false); + }); + + it('is skipped when only backend is true', () => { + const context = makeContext({ pluginJsonExtras: { backend: true } }); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/alerting.md')).toBe(false); + }); + + it('is skipped when neither alerting nor backend is set', () => { + const context = makeContext(); + catalogDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/alerting.md')).toBe(false); + }); + }); }); describe('validate-docs workflow step', () => { diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts index 24a1f635b2..0f9c24f4c8 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as v from 'valibot'; import type { Context } from '../../../context.js'; @@ -18,9 +18,21 @@ interface PluginJson { type?: string; name?: string; docsPath?: string; + annotations?: boolean; + alerting?: boolean; + backend?: boolean; [key: string]: unknown; } +// keyed by template file basename; if a copied file matches a key, it's only +// generated when the predicate returns true. Files not listed here copy +// unconditionally. +const CONDITIONAL_FILES: Record boolean> = { + 'template-variables.md': ({ basePath }) => sourceContainsVariableSupport(basePath), + 'annotations.md': ({ pluginJson }) => pluginJson.annotations === true, + 'alerting.md': ({ pluginJson }) => pluginJson.alerting === true && pluginJson.backend === true, +}; + export default function catalogDocs(context: Context, options: CatalogDocsOptions): Context { const { docsPath } = options; @@ -62,7 +74,7 @@ export default function catalogDocs(context: Context, options: CatalogDocsOption addDocsScripts(context); // step 5: copy template files to docs folder - copyDocsTemplates(context, pluginType, docsPath, pluginName); + copyDocsTemplates(context, pluginType, docsPath, pluginName, pluginJson); // step 6: copy validate-docs workflow upsertFile(context, '.github/workflows/validate-docs.yml', readTemplateFile('workflows/validate-docs.yml')); @@ -81,7 +93,13 @@ Next steps: return context; } -function copyDocsTemplates(context: Context, pluginType: string, docsPath: string, pluginName: string): void { +function copyDocsTemplates( + context: Context, + pluginType: string, + docsPath: string, + pluginName: string, + pluginJson: PluginJson +): void { const templateFolderType = pluginType === 'scenesapp' ? 'app' : pluginType; for (const typeFolder of ['common', templateFolderType]) { const templateDir = fileURLToPath(new URL(`./templates/${typeFolder}/docs`, import.meta.url)); @@ -91,6 +109,11 @@ function copyDocsTemplates(context: Context, pluginType: string, docsPath: strin for (const filePath of listFilesRecursively(templateDir)) { const relativePath = filePath.slice(templateDir.length + 1); const targetPath = `${docsPath}/${relativePath}`; + const predicate = CONDITIONAL_FILES[basename(filePath)]; + if (predicate && !predicate({ pluginJson, basePath: context.basePath })) { + additionsDebug(`${targetPath} skipped: plugin does not meet the conditions for this file`); + continue; + } if (!context.doesFileExist(targetPath)) { const content = readFileSync(filePath, 'utf-8').replaceAll('{{pluginName}}', pluginName); context.addFile(targetPath, content); @@ -108,6 +131,51 @@ function listFilesRecursively(dir: string): string[] { }); } +const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']; +const VARIABLE_SUPPORT_RE = + /\b(?:CustomVariableSupport|StandardVariableSupport|DataSourceVariableSupport|metricFindQuery)\b/; + +// scans the plugin's src/ tree for any of the four Grafana variable-support +// hooks (CustomVariableSupport, StandardVariableSupport, DataSourceVariableSupport +// or metricFindQuery). Returns true on the first match. Returns false if src/ +// doesn't exist. +function sourceContainsVariableSupport(basePath: string): boolean { + const srcDir = join(basePath, 'src'); + if (!existsSync(srcDir)) { + return false; + } + return walkForMatch(srcDir); +} + +function walkForMatch(dir: string): boolean { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') { + continue; + } + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (walkForMatch(fullPath)) { + return true; + } + continue; + } + if (!SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + continue; + } + const content = readFileSync(fullPath, 'utf-8'); + if (VARIABLE_SUPPORT_RE.test(content)) { + return true; + } + } + return false; +} + function upsertFile(context: Context, path: string, content: string): void { if (context.doesFileExist(path)) { context.updateFile(path, content); diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/alerting.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/alerting.md new file mode 100644 index 0000000000..fd116a2814 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/alerting.md @@ -0,0 +1,35 @@ +--- +title: Alerting +description: Learn how to create alert rules with the {{pluginName}} data source. +sidebar_position: 6 +--- + + + +Show how to use {{pluginName}} as a query source for Grafana-managed alert rules. Cover prerequisites, the steps to create an alert rule that queries {{pluginName}}, and concrete example alert configurations. + + + +## Before you begin + + + +List prerequisites for creating alert rules backed by {{pluginName}} - for example required permissions, the supported query shape (time series vs. table), and any feature flags or settings that must be enabled. + + + +## Create an alert rule + + + +Walk through configuring an alert rule that uses {{pluginName}} as the query source: where to start, which query/condition options are relevant, and how the rule evaluates over time. + + + +## Example alert queries + + + +Provide sample alert configurations in alert-rule JSON format - the shape stored in `dashboard.json`/provisioned-alert YAML, not free-form prose. Cover common alerting scenarios for {{pluginName}}. Pair each example with a screenshot of the rule preview where it helps. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/annotations.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/annotations.md new file mode 100644 index 0000000000..bf66d09068 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/annotations.md @@ -0,0 +1,35 @@ +--- +title: Annotations +description: Learn how to use annotations with the {{pluginName}} data source. +sidebar_position: 5 +--- + + + +Show how to create annotation queries that display events or markers on dashboards using {{pluginName}}. Cover prerequisites, configuring the annotation query, which fields map to annotation properties, and concrete examples. + + + +## Before you begin + + + +List prerequisites and setup users need before creating annotation queries with {{pluginName}} - for example required permissions, supported query shape, or any data-source-specific configuration that has to be in place. + + + +## Create an annotation query + + + +Step-by-step instructions for setting up an annotation query in {{pluginName}}: where to add the annotation, which fields the editor exposes, and any data-source-specific options users need to configure. + + + +## Example queries + + + +Provide sample annotation queries demonstrating common event scenarios (point-in-time events, ranged events). Express examples in the same shape users will paste into the annotation editor. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md index 9d7907a433..6241f6b87e 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md @@ -1,11 +1,43 @@ --- title: Configuration description: Learn how to configure the {{pluginName}} data source. -sidebar_position: 3 +sidebar_position: 2 --- -Describe how to configure a new {{pluginName}} data source instance. + -## Connection settings +Guide users through connecting the {{pluginName}} data source to Grafana. Cover prerequisites, step-by-step UI setup, every configuration field with its purpose and valid values, infrastructure-as-code provisioning, and the most common problems users hit when getting started. -## Authentication + + +## Before you begin + + + +List prerequisites, required permissions, network connectivity, and any account-level setup users must complete before configuring the {{pluginName}} data source in Grafana. + + + +## Configure the data source + + + +Walk through adding the {{pluginName}} data source through the Grafana UI step by step. Explain each field in the configuration form and include screenshots where they aid understanding. + + + +## Configuration options + + + +Reference every {{pluginName}}-specific configuration field, describing its purpose, valid values and default. Group related fields under H3 subsections (for example: Connection, Authentication, TLS, Advanced) where it improves scannability. + + + +## Provision the data source + + + +Show how to provision the {{pluginName}} data source via Grafana's YAML provisioning (and Terraform if applicable). Include a complete, copy-pasteable example covering the most common configuration. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md index b5c63b4098..1ac7014393 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md @@ -1,11 +1,27 @@ --- title: Query editor description: Learn how to use the query editor with the {{pluginName}} data source. -sidebar_position: 2 +sidebar_position: 3 --- -Describe how to build queries using the query editor. + -## Query types +Show users how to build queries against the {{pluginName}} data source. Lead with a hands-on, step-by-step walkthrough of the query editor UI, then provide concrete example queries in `dashboard.json` target format. Screenshots strongly encouraged throughout. -## Options and filters + + +## Using the query editor + + + +Walk through using the {{pluginName}} query editor as a numbered list of steps (for example: 1. Select a region. 2. Select a namespace. 3. Optionally select an aggregation.). For every field referenced in a step, describe it inline (e.g. "choose between 5 different aggregation types - default is `avg`"). Include screenshots of the editor UI alongside the steps. + + + +## Example queries + + + +Provide example queries in `dashboard.json` target format - the JSON shape used inside a panel's `targets` array - not free-form prose. Cover the most common query patterns plugin users are likely to start from. Pair each example with a screenshot of the resulting visualization where possible. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/template-variables.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/template-variables.md new file mode 100644 index 0000000000..1e5496edb7 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/template-variables.md @@ -0,0 +1,35 @@ +--- +title: Template variables +description: Learn how to use template variables with the {{pluginName}} data source. +sidebar_position: 4 +--- + + + +Explain how dashboard authors create and use template variables backed by {{pluginName}}. Cover defining query variables, referencing them inside queries, and any variable-syntax options that are specific to {{pluginName}}. + + + +## Use query variables + + + +Show how to define dashboard variables that fetch their values from {{pluginName}} through a query. Describe the supported query shape, any options the variable editor exposes (regex, sort, refresh), and how multi-value and "All" selections behave. + + + +## Use variables in queries + + + +Show how to reference template variables inside {{pluginName}} queries, with the correct interpolation syntax and any quoting or escaping considerations. Include a short example of single-value and multi-value usage. + + + +## Choose a variable syntax + + + +List the variable interpolation formats {{pluginName}} supports (for example raw, glob, regex, csv, pipe) and explain when to use each. Skip this section if the data source only supports the default format. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/troubleshooting.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/troubleshooting.md new file mode 100644 index 0000000000..ec09aba7b8 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/troubleshooting.md @@ -0,0 +1,19 @@ +--- +title: Troubleshooting +description: Troubleshoot the {{pluginName}} data source. +sidebar_position: 7 +--- + + + +List the most common problems users hit with {{pluginName}}, with diagnostic steps and resolutions for each. Use one H2 per real failure mode (for example "Connection refused", "Authentication errors", "Certificate issues") so users can scan for their specific symptom. + + + +## Common issues + + + +Replace this placeholder with one H2 per concrete failure mode users encounter with {{pluginName}}. For each issue, describe the symptom users see, the most likely root cause, and the steps to resolve it. Focus on real, observed failures - not generic advice. + + From 6ff6381fdaef751d43fd4e0de2ae023f9d2ebf9c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sun, 24 May 2026 21:26:01 +0200 Subject: [PATCH 07/13] split into two codemods --- .../src/codemods/additions/additions.ts | 11 +- .../scripts/_docs-shared/setup.test.ts | 346 ++++++++++++++++ .../index.ts => _docs-shared/setup.ts} | 136 ++++--- .../templates/workflows/validate-docs.yml | 0 .../scripts/catalog-docs/index.test.ts | 373 ------------------ .../templates/app/docs/configuration.md | 11 - .../templates/common/docs/index.md | 11 - .../templates/panel/docs/options.md | 11 - .../scripts/datasource-docs/index.test.ts | 199 ++++++++++ .../scripts/datasource-docs/index.ts | 31 ++ .../templates}/docs/alerting.md | 0 .../templates}/docs/annotations.md | 0 .../templates}/docs/configuration.md | 0 .../datasource-docs/templates/docs/index.md | 27 ++ .../templates}/docs/query-editor.md | 0 .../templates}/docs/template-variables.md | 0 .../templates}/docs/troubleshooting.md | 0 .../scripts/panel-docs/index.test.ts | 98 +++++ .../additions/scripts/panel-docs/index.ts | 19 + .../panel-docs/templates/docs/data-formats.md | 27 ++ .../panel-docs/templates/docs/examples.md | 27 ++ .../panel-docs/templates/docs/index.md | 27 ++ .../panel-docs/templates/docs/options.md | 35 ++ .../templates/docs/troubleshooting.md | 19 + 24 files changed, 939 insertions(+), 469 deletions(-) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts rename packages/create-plugin/src/codemods/additions/scripts/{catalog-docs/index.ts => _docs-shared/setup.ts} (66%) rename packages/create-plugin/src/codemods/additions/scripts/{catalog-docs => _docs-shared}/templates/workflows/validate-docs.yml (100%) delete mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts delete mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md delete mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md delete mode 100644 packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/datasource-docs/index.test.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/datasource-docs/index.ts rename packages/create-plugin/src/codemods/additions/scripts/{catalog-docs/templates/datasource => datasource-docs/templates}/docs/alerting.md (100%) rename packages/create-plugin/src/codemods/additions/scripts/{catalog-docs/templates/datasource => datasource-docs/templates}/docs/annotations.md (100%) rename packages/create-plugin/src/codemods/additions/scripts/{catalog-docs/templates/datasource => datasource-docs/templates}/docs/configuration.md (100%) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/index.md rename packages/create-plugin/src/codemods/additions/scripts/{catalog-docs/templates/datasource => datasource-docs/templates}/docs/query-editor.md (100%) rename packages/create-plugin/src/codemods/additions/scripts/{catalog-docs/templates/datasource => datasource-docs/templates}/docs/template-variables.md (100%) rename packages/create-plugin/src/codemods/additions/scripts/{catalog-docs/templates/datasource => datasource-docs/templates}/docs/troubleshooting.md (100%) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/panel-docs/index.test.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/panel-docs/index.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/data-formats.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/examples.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/index.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/options.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/troubleshooting.md diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index 6a14b3edd8..7362cf8efa 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -12,8 +12,13 @@ export default [ scriptPath: import.meta.resolve('./scripts/externalize-jsx-runtime.js'), }, { - name: 'catalog-docs', - description: 'Enables multi-page docs for the Grafana Plugin Catalog', - scriptPath: import.meta.resolve('./scripts/catalog-docs/index.js'), + name: 'datasource-docs', + description: 'Scaffolds multi-page documentation for a Grafana datasource plugin', + scriptPath: import.meta.resolve('./scripts/datasource-docs/index.js'), + }, + { + name: 'panel-docs', + description: 'Scaffolds multi-page documentation for a Grafana panel plugin', + scriptPath: import.meta.resolve('./scripts/panel-docs/index.js'), }, ] satisfies Codemod[]; diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts new file mode 100644 index 0000000000..2ee3fdb1d1 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts @@ -0,0 +1,346 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Context } from '../../../context.js'; +import { + assertPluginType, + type ConditionalFilePredicate, + setupDocsScaffolding, + sourceContainsVariableSupport, +} from './setup.js'; + +// capture the real existsSync before mocking so we can delegate to it in beforeEach +const { existsSync: realExistsSync } = await vi.importActual('node:fs'); + +vi.mock('node:fs', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + existsSync: vi.fn().mockImplementation(mod.existsSync), + }; +}); + +interface MakeContextOptions { + pluginJson?: Record; + basePath?: string; +} + +function makeContext(opts: MakeContextOptions = {}): Context { + const { pluginJson = { type: 'datasource', name: 'My Plugin' }, basePath = '/virtual' } = opts; + const context = new Context(basePath); + context.addFile('src/plugin.json', JSON.stringify(pluginJson)); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); + return context; +} + +describe('_docs-shared/setup', () => { + const tempDirs: string[] = []; + + function makeTempPluginDir(srcFiles: Record = {}): string { + const dir = mkdtempSync(join(tmpdir(), 'docs-shared-test-')); + tempDirs.push(dir); + if (Object.keys(srcFiles).length > 0) { + mkdirSync(join(dir, 'src'), { recursive: true }); + for (const [relPath, content] of Object.entries(srcFiles)) { + const target = join(dir, 'src', relPath); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, content); + } + } + return dir; + } + + // build a synthetic templateBaseUrl folder with the given file contents + function makeTemplateBaseUrl(files: Record): URL { + const dir = mkdtempSync(join(tmpdir(), 'docs-templates-')); + tempDirs.push(dir); + mkdirSync(join(dir, 'docs'), { recursive: true }); + mkdirSync(join(dir, 'templates', 'workflows'), { recursive: true }); + // The shared module reads workflows/validate-docs.yml relative to its own location - + // we can't override that from the outside, so this helper is only for /docs templates. + for (const [relPath, content] of Object.entries(files)) { + const target = join(dir, 'docs', relPath); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, content); + } + return pathToFileURL(`${dir}/`); + } + + beforeEach(() => { + vi.mocked(existsSync).mockImplementation(realExistsSync); + }); + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + describe('setupDocsScaffolding', () => { + function call( + context: Context, + overrides: { + templates?: Record; + conditionalFiles?: Record; + docsPath?: string; + } = {} + ): void { + const templates = overrides.templates ?? { 'index.md': '# {{pluginName}}\n' }; + setupDocsScaffolding({ + context, + docsPath: overrides.docsPath ?? 'docs', + templateBaseUrl: makeTemplateBaseUrl(templates), + codemodName: 'test-docs', + conditionalFiles: overrides.conditionalFiles, + }); + } + + describe('early exit', () => { + it('throws if docs directory already exists on disk', () => { + vi.mocked(existsSync).mockReturnValueOnce(true); + const context = makeContext(); + expect(() => call(context)).toThrow("A directory already exists at 'docs'"); + }); + }); + + describe('plugin.json step', () => { + it('adds docsPath to src/plugin.json', () => { + const context = makeContext(); + call(context); + const parsed = JSON.parse(context.getFile('src/plugin.json') ?? '{}'); + expect(parsed.docsPath).toBe('docs'); + }); + + it('throws if src/plugin.json is missing', () => { + const context = new Context('/virtual'); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + expect(() => call(context)).toThrow('Cannot find src/plugin.json'); + }); + + it('skips docsPath update when already set to a different value', () => { + const context = makeContext({ + pluginJson: { type: 'datasource', name: 'My Plugin', docsPath: 'custom-docs' }, + }); + call(context); + const parsed = JSON.parse(context.getFile('src/plugin.json') ?? '{}'); + expect(parsed.docsPath).toBe('custom-docs'); + }); + + it('uses a custom docsPath when specified', () => { + const context = makeContext(); + call(context, { docsPath: 'my-docs' }); + expect(context.doesFileExist('my-docs/index.md')).toBe(true); + }); + }); + + describe('devDependency and scripts', () => { + it('adds @grafana/plugin-docs-cli to devDependencies', () => { + const context = makeContext(); + call(context); + const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); + expect(pkg.devDependencies?.['@grafana/plugin-docs-cli']).toBe('^0.0.10'); + }); + + it('adds docs:serve and docs:validate scripts', () => { + const context = makeContext(); + call(context); + const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); + expect(pkg.scripts?.['docs:serve']).toBe('plugin-docs-cli serve --port 3001 --reload'); + expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate --strict'); + }); + + it('skips docs:serve if already present', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); + context.addFile( + 'package.json', + JSON.stringify({ scripts: { 'docs:serve': 'custom-command' }, devDependencies: {} }) + ); + context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); + call(context); + const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); + expect(pkg.scripts?.['docs:serve']).toBe('custom-command'); + expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate --strict'); + }); + }); + + describe('template copy', () => { + it('copies every template file under docs/', () => { + const context = makeContext(); + call(context, { + templates: { + 'index.md': '# Overview\n', + 'configuration.md': '## Configure\n', + 'nested/deep.md': 'nested\n', + }, + }); + expect(context.doesFileExist('docs/index.md')).toBe(true); + expect(context.doesFileExist('docs/configuration.md')).toBe(true); + expect(context.doesFileExist('docs/nested/deep.md')).toBe(true); + }); + + it('interpolates {{pluginName}} in template content', () => { + const context = makeContext(); + call(context, { templates: { 'index.md': '# {{pluginName}}\n' } }); + expect(context.getFile('docs/index.md')).toContain('My Plugin'); + expect(context.getFile('docs/index.md')).not.toContain('{{pluginName}}'); + }); + + it('skips a file already present in the context', () => { + const context = makeContext(); + context.addFile('docs/index.md', '# Existing\n'); + call(context, { templates: { 'index.md': '# Replacement\n' } }); + expect(context.getFile('docs/index.md')).toBe('# Existing\n'); + }); + + it('honors a conditional file predicate when it returns true', () => { + const context = makeContext({ pluginJson: { type: 'datasource', name: 'X', annotations: true } }); + call(context, { + templates: { 'annotations.md': '# {{pluginName}}\n' }, + conditionalFiles: { 'annotations.md': ({ pluginJson }) => pluginJson.annotations === true }, + }); + expect(context.doesFileExist('docs/annotations.md')).toBe(true); + }); + + it('honors a conditional file predicate when it returns false', () => { + const context = makeContext(); + call(context, { + templates: { 'annotations.md': '# {{pluginName}}\n' }, + conditionalFiles: { 'annotations.md': ({ pluginJson }) => pluginJson.annotations === true }, + }); + expect(context.doesFileExist('docs/annotations.md')).toBe(false); + }); + }); + + describe('validate-docs workflow', () => { + it('creates .github/workflows/validate-docs.yml', () => { + const context = makeContext(); + call(context); + expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true); + expect(context.getFile('.github/workflows/validate-docs.yml')).toContain('plugin-docs-cli validate --strict'); + }); + + it('overwrites an existing .github/workflows/validate-docs.yml', () => { + const context = makeContext(); + context.addFile('.github/workflows/validate-docs.yml', 'old content'); + call(context); + expect(context.getFile('.github/workflows/validate-docs.yml')).not.toBe('old content'); + }); + }); + + describe('release.yml build-plugin bump', () => { + it('bumps build-plugin ref in release.yml', () => { + const context = makeContext(); + call(context); + const content = context.getFile('.github/workflows/release.yml') ?? ''; + expect(content).toContain('grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step'); + }); + + it('handles multiple build-plugin refs', () => { + const context = makeContext(); + context.updateFile( + '.github/workflows/release.yml', + 'uses: grafana/plugin-actions/build-plugin@v1.0.0\nuses: grafana/plugin-actions/build-plugin@v2.0.0\n' + ); + call(context); + expect(context.getFile('.github/workflows/release.yml')).toBe( + 'uses: grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step\nuses: grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step\n' + ); + }); + + it('skips when release.yml has no build-plugin reference', () => { + const context = makeContext(); + context.updateFile('.github/workflows/release.yml', 'name: Release\n'); + call(context); + expect(context.getFile('.github/workflows/release.yml')).toBe('name: Release\n'); + }); + + it('skips when release.yml does not exist', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + expect(() => call(context)).not.toThrow(); + }); + }); + }); + + describe('assertPluginType', () => { + it('returns the parsed plugin.json when the type matches', () => { + const context = makeContext({ pluginJson: { type: 'datasource', name: 'X' } }); + const parsed = assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' }); + expect(parsed.name).toBe('X'); + }); + + it('throws when the type does not match', () => { + const context = makeContext({ pluginJson: { type: 'panel', name: 'X' } }); + expect(() => assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' })).toThrow( + /only works on 'datasource' plugins.*type is 'panel'/ + ); + }); + + it('points the user at the sibling codemod in the error message', () => { + const context = makeContext({ pluginJson: { type: 'panel', name: 'X' } }); + expect(() => assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' })).toThrow( + /create-plugin add panel-docs/ + ); + }); + + it('throws when plugin.json is missing', () => { + const context = new Context('/virtual'); + expect(() => assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' })).toThrow( + 'Cannot find src/plugin.json' + ); + }); + + it('throws when type is unset', () => { + const context = makeContext({ pluginJson: { name: 'X' } }); + expect(() => assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' })).toThrow( + /type is 'unset'/ + ); + }); + }); + + describe('sourceContainsVariableSupport', () => { + it.each([ + ['metricFindQuery', 'export function metricFindQuery(q: string) { return []; }\n'], + [ + 'CustomVariableSupport', + 'import { CustomVariableSupport } from "@grafana/data"; class V extends CustomVariableSupport {}\n', + ], + [ + 'StandardVariableSupport', + 'import { StandardVariableSupport } from "@grafana/data"; class V extends StandardVariableSupport {}\n', + ], + [ + 'DataSourceVariableSupport', + 'import { DataSourceVariableSupport } from "@grafana/data"; class V extends DataSourceVariableSupport {}\n', + ], + ])('returns true when src contains %s', (_, source) => { + const basePath = makeTempPluginDir({ 'datasource.ts': source }); + expect(sourceContainsVariableSupport(basePath)).toBe(true); + }); + + it('finds tokens in nested directories', () => { + const basePath = makeTempPluginDir({ + 'nested/deep/queries.ts': 'export const x = (ds: any) => ds.metricFindQuery("foo");\n', + }); + expect(sourceContainsVariableSupport(basePath)).toBe(true); + }); + + it('returns false when no token is found', () => { + const basePath = makeTempPluginDir({ 'datasource.ts': 'export function query(q: string) { return []; }\n' }); + expect(sourceContainsVariableSupport(basePath)).toBe(false); + }); + + it('returns false when src directory does not exist', () => { + const basePath = makeTempPluginDir(); + expect(sourceContainsVariableSupport(basePath)).toBe(false); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts similarity index 66% rename from packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts rename to packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts index 0f9c24f4c8..51e0655d28 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts @@ -1,20 +1,13 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { basename, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import * as v from 'valibot'; import type { Context } from '../../../context.js'; import { additionsDebug, addDependenciesToPackageJson } from '../../../utils.js'; -export const schema = v.object({ - docsPath: v.optional(v.string(), 'docs'), -}); - -type CatalogDocsOptions = v.InferOutput; - // TODO: replace with stable tag once plugin-actions PR #219 merges const REQUIRED_BUILD_PLUGIN_REF = 'eriksundell/plugin-docs-build-step'; -interface PluginJson { +export interface PluginJson { type?: string; name?: string; docsPath?: string; @@ -24,22 +17,23 @@ interface PluginJson { [key: string]: unknown; } -// keyed by template file basename; if a copied file matches a key, it's only -// generated when the predicate returns true. Files not listed here copy -// unconditionally. -const CONDITIONAL_FILES: Record boolean> = { - 'template-variables.md': ({ basePath }) => sourceContainsVariableSupport(basePath), - 'annotations.md': ({ pluginJson }) => pluginJson.annotations === true, - 'alerting.md': ({ pluginJson }) => pluginJson.alerting === true && pluginJson.backend === true, -}; +export type ConditionalFilePredicate = (ctx: { pluginJson: PluginJson; basePath: string }) => boolean; + +export interface DocsSetupOptions { + context: Context; + docsPath: string; + templateBaseUrl: URL; + codemodName: string; + conditionalFiles?: Record; +} -export default function catalogDocs(context: Context, options: CatalogDocsOptions): Context { - const { docsPath } = options; +export function setupDocsScaffolding(opts: DocsSetupOptions): Context { + const { context, docsPath, templateBaseUrl, codemodName, conditionalFiles = {} } = opts; // step 1: early exit if the docs directory already exists on disk if (existsSync(join(context.basePath, docsPath))) { throw new Error( - `A directory already exists at '${docsPath}'. Re-run with a different path:\n create-plugin add catalog-docs --docsPath ` + `A directory already exists at '${docsPath}'. Re-run with a different path:\n create-plugin add ${codemodName} --docsPath ` ); } @@ -63,8 +57,6 @@ export default function catalogDocs(context: Context, options: CatalogDocsOption context.updateFile('src/plugin.json', JSON.stringify({ ...pluginJson, docsPath }, null, 2)); } - // extract type and name for use in later steps - const pluginType = pluginJson.type ?? 'app'; const pluginName = pluginJson.name ?? 'my-plugin'; // step 3: add @grafana/plugin-docs-cli as a devDependency @@ -74,10 +66,10 @@ export default function catalogDocs(context: Context, options: CatalogDocsOption addDocsScripts(context); // step 5: copy template files to docs folder - copyDocsTemplates(context, pluginType, docsPath, pluginName, pluginJson); + copyDocsTemplates(context, templateBaseUrl, docsPath, pluginName, pluginJson, conditionalFiles); - // step 6: copy validate-docs workflow - upsertFile(context, '.github/workflows/validate-docs.yml', readTemplateFile('workflows/validate-docs.yml')); + // step 6: copy validate-docs workflow from the shared templates folder (same dir as this file) + upsertFile(context, '.github/workflows/validate-docs.yml', readSharedTemplate('workflows/validate-docs.yml')); // step 7: bump build-plugin version in release.yml bumpBuildPluginVersion(context); @@ -93,42 +85,30 @@ Next steps: return context; } -function copyDocsTemplates( +// parses src/plugin.json from the context and verifies its `type` matches the +// expected value. Throws a helpful error otherwise. Returns the parsed object +// so callers don't have to reparse. +export function assertPluginType( context: Context, - pluginType: string, - docsPath: string, - pluginName: string, - pluginJson: PluginJson -): void { - const templateFolderType = pluginType === 'scenesapp' ? 'app' : pluginType; - for (const typeFolder of ['common', templateFolderType]) { - const templateDir = fileURLToPath(new URL(`./templates/${typeFolder}/docs`, import.meta.url)); - if (!existsSync(templateDir)) { - continue; - } - for (const filePath of listFilesRecursively(templateDir)) { - const relativePath = filePath.slice(templateDir.length + 1); - const targetPath = `${docsPath}/${relativePath}`; - const predicate = CONDITIONAL_FILES[basename(filePath)]; - if (predicate && !predicate({ pluginJson, basePath: context.basePath })) { - additionsDebug(`${targetPath} skipped: plugin does not meet the conditions for this file`); - continue; - } - if (!context.doesFileExist(targetPath)) { - const content = readFileSync(filePath, 'utf-8').replaceAll('{{pluginName}}', pluginName); - context.addFile(targetPath, content); - } else { - additionsDebug(`${targetPath} already exists, skipping`); - } - } + opts: { expectedType: 'datasource' | 'panel'; codemodName: string } +): PluginJson { + const raw = context.getFile('src/plugin.json'); + if (raw === undefined) { + throw new Error('Cannot find src/plugin.json. Run this command from the plugin root directory.'); } -} - -function listFilesRecursively(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const fullPath = join(dir, entry.name); - return entry.isDirectory() ? listFilesRecursively(fullPath) : [fullPath]; - }); + let parsed: PluginJson; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error(`Cannot parse src/plugin.json: ${e}`); + } + if (parsed.type !== opts.expectedType) { + const otherCommand = opts.expectedType === 'datasource' ? 'panel-docs' : 'datasource-docs'; + throw new Error( + `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.` + ); + } + return parsed; } const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']; @@ -139,7 +119,7 @@ const VARIABLE_SUPPORT_RE = // hooks (CustomVariableSupport, StandardVariableSupport, DataSourceVariableSupport // or metricFindQuery). Returns true on the first match. Returns false if src/ // doesn't exist. -function sourceContainsVariableSupport(basePath: string): boolean { +export function sourceContainsVariableSupport(basePath: string): boolean { const srcDir = join(basePath, 'src'); if (!existsSync(srcDir)) { return false; @@ -176,6 +156,42 @@ function walkForMatch(dir: string): boolean { return false; } +function copyDocsTemplates( + context: Context, + templateBaseUrl: URL, + docsPath: string, + pluginName: string, + pluginJson: PluginJson, + conditionalFiles: Record +): void { + const docsTemplateDir = fileURLToPath(new URL('./docs', templateBaseUrl)); + if (!existsSync(docsTemplateDir)) { + return; + } + for (const filePath of listFilesRecursively(docsTemplateDir)) { + const relativePath = filePath.slice(docsTemplateDir.length + 1); + const targetPath = `${docsPath}/${relativePath}`; + const predicate = conditionalFiles[basename(filePath)]; + if (predicate && !predicate({ pluginJson, basePath: context.basePath })) { + additionsDebug(`${targetPath} skipped: plugin does not meet the conditions for this file`); + continue; + } + if (!context.doesFileExist(targetPath)) { + const content = readFileSync(filePath, 'utf-8').replaceAll('{{pluginName}}', pluginName); + context.addFile(targetPath, content); + } else { + additionsDebug(`${targetPath} already exists, skipping`); + } + } +} + +function listFilesRecursively(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = join(dir, entry.name); + return entry.isDirectory() ? listFilesRecursively(fullPath) : [fullPath]; + }); +} + function upsertFile(context: Context, path: string, content: string): void { if (context.doesFileExist(path)) { context.updateFile(path, content); @@ -184,7 +200,7 @@ function upsertFile(context: Context, path: string, content: string): void { } } -function readTemplateFile(relativePath: string): string { +function readSharedTemplate(relativePath: string): string { const templatePath = fileURLToPath(new URL(`./templates/${relativePath}`, import.meta.url)); return readFileSync(templatePath, 'utf-8'); } diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/workflows/validate-docs.yml similarity index 100% rename from packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/workflows/validate-docs.yml rename to packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/workflows/validate-docs.yml diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts deleted file mode 100644 index f09ec02137..0000000000 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/index.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Context } from '../../../context.js'; -import catalogDocs from './index.js'; - -// capture the real existsSync before mocking so we can delegate to it in beforeEach -const { existsSync: realExistsSync } = await vi.importActual('node:fs'); - -vi.mock('node:fs', async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - existsSync: vi.fn().mockImplementation(mod.existsSync), - }; -}); - -interface MakeContextOptions { - pluginType?: string; - pluginJsonExtras?: Record; - basePath?: string; -} - -function makeContext(pluginTypeOrOpts: string | MakeContextOptions = 'datasource'): Context { - const opts: MakeContextOptions = - typeof pluginTypeOrOpts === 'string' ? { pluginType: pluginTypeOrOpts } : pluginTypeOrOpts; - const { pluginType = 'datasource', pluginJsonExtras = {}, basePath = '/virtual' } = opts; - const context = new Context(basePath); - context.addFile('src/plugin.json', JSON.stringify({ type: pluginType, name: 'My Plugin', ...pluginJsonExtras })); - context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); - context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); - return context; -} - -describe('catalog-docs codemod', () => { - const tempDirs: string[] = []; - - function makeTempPluginDir(srcFiles: Record = {}): string { - const dir = mkdtempSync(join(tmpdir(), 'catalog-docs-test-')); - tempDirs.push(dir); - if (Object.keys(srcFiles).length > 0) { - mkdirSync(join(dir, 'src'), { recursive: true }); - for (const [relPath, content] of Object.entries(srcFiles)) { - const target = join(dir, 'src', relPath); - mkdirSync(join(target, '..'), { recursive: true }); - writeFileSync(target, content); - } - } - return dir; - } - - beforeEach(() => { - vi.mocked(existsSync).mockImplementation(realExistsSync); - }); - - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) { - rmSync(dir, { recursive: true, force: true }); - } - } - }); - - describe('early exit', () => { - it('throws if docs directory already exists on disk', () => { - vi.mocked(existsSync).mockReturnValueOnce(true); - const context = makeContext(); - expect(() => catalogDocs(context, { docsPath: 'docs' })).toThrow("A directory already exists at 'docs'"); - }); - }); - - describe('plugin.json step', () => { - it('adds docsPath to src/plugin.json', () => { - const context = makeContext(); - catalogDocs(context, { docsPath: 'docs' }); - const pluginJson = JSON.parse(context.getFile('src/plugin.json') ?? '{}'); - expect(pluginJson.docsPath).toBe('docs'); - }); - - it('throws if src/plugin.json is missing', () => { - const context = new Context('/virtual'); - context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); - expect(() => catalogDocs(context, { docsPath: 'docs' })).toThrow('Cannot find src/plugin.json'); - }); - - it('skips docsPath update when already set to a different value', () => { - const context = new Context('/virtual'); - context.addFile( - 'src/plugin.json', - JSON.stringify({ type: 'datasource', name: 'My Plugin', docsPath: 'custom-docs' }) - ); - context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); - context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); - catalogDocs(context, { docsPath: 'docs' }); - const pluginJson = JSON.parse(context.getFile('src/plugin.json') ?? '{}'); - expect(pluginJson.docsPath).toBe('custom-docs'); // unchanged - }); - }); - - describe('devDependency and scripts step', () => { - it('adds @grafana/plugin-docs-cli to devDependencies', () => { - const context = makeContext(); - catalogDocs(context, { docsPath: 'docs' }); - const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); - expect(pkg.devDependencies?.['@grafana/plugin-docs-cli']).toBe('^0.0.10'); - }); - - it('adds docs:serve and docs:validate scripts', () => { - const context = makeContext(); - catalogDocs(context, { docsPath: 'docs' }); - const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); - expect(pkg.scripts?.['docs:serve']).toBe('plugin-docs-cli serve --port 3001 --reload'); - expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate --strict'); - }); - - it('skips docs:serve if already present', () => { - const context = new Context('/virtual'); - context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); - context.addFile( - 'package.json', - JSON.stringify({ scripts: { 'docs:serve': 'custom-command' }, devDependencies: {} }) - ); - context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); - catalogDocs(context, { docsPath: 'docs' }); - const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); - expect(pkg.scripts?.['docs:serve']).toBe('custom-command'); // unchanged - expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate --strict'); // new - }); - }); - - describe('docs folder creation step', () => { - it('creates docs/index.md for all plugin types', () => { - const context = makeContext('datasource'); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/index.md')).toBe(true); - }); - - it('creates type-specific docs files for datasource', () => { - const context = makeContext('datasource'); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/query-editor.md')).toBe(true); - expect(context.doesFileExist('docs/configuration.md')).toBe(true); - }); - - it('creates type-specific docs files for panel', () => { - const context = makeContext('panel'); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/options.md')).toBe(true); - }); - - it('creates type-specific docs files for app', () => { - const context = makeContext('app'); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/configuration.md')).toBe(true); - }); - - it('uses app templates for scenesapp plugin type', () => { - const context = makeContext('scenesapp'); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/configuration.md')).toBe(true); - }); - - it('interpolates pluginName in template content', () => { - const context = makeContext('datasource'); - catalogDocs(context, { docsPath: 'docs' }); - const content = context.getFile('docs/index.md') ?? ''; - expect(content).toContain('My Plugin'); - expect(content).not.toContain('{{pluginName}}'); - }); - - it('skips existing files in the context', () => { - const context = makeContext('datasource'); - context.addFile('docs/index.md', '# Existing content'); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.getFile('docs/index.md')).toBe('# Existing content'); - }); - - it('uses a custom docsPath when specified', () => { - const context = makeContext('datasource'); - catalogDocs(context, { docsPath: 'my-docs' }); - expect(context.doesFileExist('my-docs/index.md')).toBe(true); - }); - - it('generates the standard datasource H2 headings in configuration.md', () => { - const context = makeContext('datasource'); - catalogDocs(context, { docsPath: 'docs' }); - const content = context.getFile('docs/configuration.md') ?? ''; - expect(content).toContain('## Before you begin'); - expect(content).toContain('## Configure the data source'); - expect(content).toContain('## Configuration options'); - expect(content).toContain('## Provision the data source'); - }); - - it('generates troubleshooting.md as its own page', () => { - const context = makeContext('datasource'); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/troubleshooting.md')).toBe(true); - const content = context.getFile('docs/troubleshooting.md') ?? ''; - expect(content).toContain('## Common issues'); - }); - - it('wraps each section in agent-hint blocks', () => { - const context = makeContext('datasource'); - catalogDocs(context, { docsPath: 'docs' }); - const content = context.getFile('docs/configuration.md') ?? ''; - expect(content).toContain(''); - expect(content).toContain(''); - }); - }); - - describe('conditional template files', () => { - describe('template-variables.md', () => { - it.each([ - ['metricFindQuery', 'export function metricFindQuery(q: string) { return []; }\n'], - [ - 'CustomVariableSupport', - 'import { CustomVariableSupport } from "@grafana/data"; class V extends CustomVariableSupport {}\n', - ], - [ - 'StandardVariableSupport', - 'import { StandardVariableSupport } from "@grafana/data"; class V extends StandardVariableSupport {}\n', - ], - [ - 'DataSourceVariableSupport', - 'import { DataSourceVariableSupport } from "@grafana/data"; class V extends DataSourceVariableSupport {}\n', - ], - ])('is generated when src contains %s', (_, source) => { - const basePath = makeTempPluginDir({ 'datasource.ts': source }); - const context = makeContext({ basePath }); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/template-variables.md')).toBe(true); - const content = context.getFile('docs/template-variables.md') ?? ''; - expect(content).toContain('## Use query variables'); - }); - - it('finds variable-support tokens in nested directories', () => { - const basePath = makeTempPluginDir({ - 'nested/deep/queries.ts': 'export const x = (ds: any) => ds.metricFindQuery("foo");\n', - }); - const context = makeContext({ basePath }); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/template-variables.md')).toBe(true); - }); - - it('is skipped when no variable-support token is found', () => { - const basePath = makeTempPluginDir({ - 'datasource.ts': 'export function query(q: string) { return []; }\n', - }); - const context = makeContext({ basePath }); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/template-variables.md')).toBe(false); - }); - - it('is skipped when src directory does not exist', () => { - const basePath = makeTempPluginDir(); - const context = makeContext({ basePath }); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/template-variables.md')).toBe(false); - }); - }); - - describe('annotations.md', () => { - it('is generated when plugin.json sets annotations: true', () => { - const context = makeContext({ pluginJsonExtras: { annotations: true } }); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/annotations.md')).toBe(true); - const content = context.getFile('docs/annotations.md') ?? ''; - expect(content).toContain('## Create an annotation query'); - }); - - it('is skipped when plugin.json omits annotations', () => { - const context = makeContext(); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/annotations.md')).toBe(false); - }); - - it('is skipped when plugin.json sets annotations: false', () => { - const context = makeContext({ pluginJsonExtras: { annotations: false } }); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/annotations.md')).toBe(false); - }); - }); - - describe('alerting.md', () => { - it('is generated when both alerting and backend are true', () => { - const context = makeContext({ pluginJsonExtras: { alerting: true, backend: true } }); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/alerting.md')).toBe(true); - const content = context.getFile('docs/alerting.md') ?? ''; - expect(content).toContain('## Create an alert rule'); - }); - - it('is skipped when only alerting is true', () => { - const context = makeContext({ pluginJsonExtras: { alerting: true } }); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/alerting.md')).toBe(false); - }); - - it('is skipped when only backend is true', () => { - const context = makeContext({ pluginJsonExtras: { backend: true } }); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/alerting.md')).toBe(false); - }); - - it('is skipped when neither alerting nor backend is set', () => { - const context = makeContext(); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('docs/alerting.md')).toBe(false); - }); - }); - }); - - describe('validate-docs workflow step', () => { - it('creates .github/workflows/validate-docs.yml', () => { - const context = makeContext(); - catalogDocs(context, { docsPath: 'docs' }); - expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true); - }); - - it('overwrites existing .github/workflows/validate-docs.yml', () => { - const context = makeContext(); - context.addFile('.github/workflows/validate-docs.yml', 'old content'); - catalogDocs(context, { docsPath: 'docs' }); - const content = context.getFile('.github/workflows/validate-docs.yml') ?? ''; - expect(content).not.toBe('old content'); - expect(content).toContain('plugin-docs-cli validate --strict'); - }); - }); - - describe('release.yml build-plugin bump step', () => { - it('bumps build-plugin ref in release.yml', () => { - const context = makeContext(); - catalogDocs(context, { docsPath: 'docs' }); - const content = context.getFile('.github/workflows/release.yml') ?? ''; - expect(content).toContain('grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step'); - }); - - it('handles multiple build-plugin refs in release.yml', () => { - const context = new Context('/virtual'); - context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); - context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); - context.addFile( - '.github/workflows/release.yml', - 'uses: grafana/plugin-actions/build-plugin@v1.0.0\nuses: grafana/plugin-actions/build-plugin@v2.0.0\n' - ); - catalogDocs(context, { docsPath: 'docs' }); - const content = context.getFile('.github/workflows/release.yml') ?? ''; - expect(content).toBe( - 'uses: grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step\nuses: grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step\n' - ); - }); - - it('skips gracefully when release.yml has no build-plugin reference', () => { - const context = new Context('/virtual'); - context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); - context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); - context.addFile('.github/workflows/release.yml', 'name: Release\n'); - catalogDocs(context, { docsPath: 'docs' }); - const content = context.getFile('.github/workflows/release.yml') ?? ''; - expect(content).toBe('name: Release\n'); // unchanged - }); - - it('skips gracefully when release.yml does not exist', () => { - const context = new Context('/virtual'); - context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); - context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); - // no release.yml - expect(() => catalogDocs(context, { docsPath: 'docs' })).not.toThrow(); - }); - }); -}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md deleted file mode 100644 index f0c1643199..0000000000 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/app/docs/configuration.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Configuration -description: Learn how to configure the {{pluginName}} app. -sidebar_position: 2 ---- - -Describe the configuration options for the {{pluginName}} app plugin. - -## App settings - -## Provisioning diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md deleted file mode 100644 index c7d1190e5b..0000000000 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/common/docs/index.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Overview -description: Learn about the {{pluginName}} plugin. -sidebar_position: 1 ---- - -Describe what this plugin does and what problem it solves. - -## Features - -## Requirements diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md b/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md deleted file mode 100644 index 8eb212b15e..0000000000 --- a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/panel/docs/options.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Panel options -description: Reference for {{pluginName}} panel options. -sidebar_position: 2 ---- - -List and describe the options available in the panel editor. - -## Standard options - -## Custom options diff --git a/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/index.test.ts new file mode 100644 index 0000000000..84c6061ce5 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/index.test.ts @@ -0,0 +1,199 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Context } from '../../../context.js'; +import datasourceDocs from './index.js'; + +const { existsSync: realExistsSync } = await vi.importActual('node:fs'); + +vi.mock('node:fs', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + existsSync: vi.fn().mockImplementation(mod.existsSync), + }; +}); + +interface MakeContextOptions { + pluginJsonExtras?: Record; + basePath?: string; +} + +function makeContext(opts: MakeContextOptions = {}): Context { + const { pluginJsonExtras = {}, basePath = '/virtual' } = opts; + const context = new Context(basePath); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin', ...pluginJsonExtras })); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); + return context; +} + +describe('datasource-docs codemod', () => { + const tempDirs: string[] = []; + + function makeTempPluginDir(srcFiles: Record = {}): string { + const dir = mkdtempSync(join(tmpdir(), 'datasource-docs-test-')); + tempDirs.push(dir); + if (Object.keys(srcFiles).length > 0) { + mkdirSync(join(dir, 'src'), { recursive: true }); + for (const [relPath, content] of Object.entries(srcFiles)) { + const target = join(dir, 'src', relPath); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, content); + } + } + return dir; + } + + beforeEach(() => { + vi.mocked(existsSync).mockImplementation(realExistsSync); + }); + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + describe('type guard', () => { + it('errors when plugin.json type is panel', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', name: 'X' })); + expect(() => datasourceDocs(context, { docsPath: 'docs' })).toThrow( + /only works on 'datasource'.*type is 'panel'.*panel-docs/s + ); + }); + + it('errors when plugin.json type is app', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'app', name: 'X' })); + expect(() => datasourceDocs(context, { docsPath: 'docs' })).toThrow(/only works on 'datasource'/); + }); + + it('errors when plugin.json type is unset', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ name: 'X' })); + expect(() => datasourceDocs(context, { docsPath: 'docs' })).toThrow(/type is 'unset'/); + }); + }); + + describe('generated files', () => { + it('creates the universal datasource docs files', () => { + const context = makeContext(); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/index.md')).toBe(true); + expect(context.doesFileExist('docs/configuration.md')).toBe(true); + expect(context.doesFileExist('docs/query-editor.md')).toBe(true); + expect(context.doesFileExist('docs/troubleshooting.md')).toBe(true); + }); + + it('uses the expected H2s in configuration.md', () => { + const context = makeContext(); + datasourceDocs(context, { docsPath: 'docs' }); + const content = context.getFile('docs/configuration.md') ?? ''; + expect(content).toContain('## Before you begin'); + expect(content).toContain('## Configure the data source'); + expect(content).toContain('## Configuration options'); + expect(content).toContain('## Provision the data source'); + }); + + it('uses the expected H2s in query-editor.md', () => { + const context = makeContext(); + datasourceDocs(context, { docsPath: 'docs' }); + const content = context.getFile('docs/query-editor.md') ?? ''; + expect(content).toContain('## Using the query editor'); + expect(content).toContain('## Example queries'); + }); + + it('wraps generated sections in agent-hint blocks', () => { + const context = makeContext(); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.getFile('docs/configuration.md') ?? '').toContain(''); + expect(context.getFile('docs/index.md') ?? '').toContain(''); + }); + + it('writes the validate-docs workflow', () => { + const context = makeContext(); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true); + }); + + it('bumps the build-plugin ref in release.yml', () => { + const context = makeContext(); + datasourceDocs(context, { docsPath: 'docs' }); + const content = context.getFile('.github/workflows/release.yml') ?? ''; + expect(content).toContain('grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step'); + }); + }); + + describe('conditional files', () => { + describe('template-variables.md', () => { + it('is generated when src contains metricFindQuery', () => { + const basePath = makeTempPluginDir({ + 'datasource.ts': 'export function metricFindQuery(q: string) { return []; }\n', + }); + const context = makeContext({ basePath }); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/template-variables.md')).toBe(true); + expect(context.getFile('docs/template-variables.md') ?? '').toContain('## Use query variables'); + }); + + it('is generated when src contains CustomVariableSupport', () => { + const basePath = makeTempPluginDir({ + 'variables.ts': + 'import { CustomVariableSupport } from "@grafana/data";\nclass V extends CustomVariableSupport {}\n', + }); + const context = makeContext({ basePath }); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/template-variables.md')).toBe(true); + }); + + it('is skipped when no variable-support token is present', () => { + const basePath = makeTempPluginDir({ 'datasource.ts': 'export function query() {}\n' }); + const context = makeContext({ basePath }); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/template-variables.md')).toBe(false); + }); + }); + + describe('annotations.md', () => { + it('is generated when plugin.json sets annotations: true', () => { + const context = makeContext({ pluginJsonExtras: { annotations: true } }); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/annotations.md')).toBe(true); + expect(context.getFile('docs/annotations.md') ?? '').toContain('## Create an annotation query'); + }); + + it('is skipped when annotations is omitted', () => { + const context = makeContext(); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/annotations.md')).toBe(false); + }); + }); + + describe('alerting.md', () => { + it('is generated when both alerting and backend are true', () => { + const context = makeContext({ pluginJsonExtras: { alerting: true, backend: true } }); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/alerting.md')).toBe(true); + expect(context.getFile('docs/alerting.md') ?? '').toContain('## Create an alert rule'); + }); + + it('is skipped when only alerting is true', () => { + const context = makeContext({ pluginJsonExtras: { alerting: true } }); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/alerting.md')).toBe(false); + }); + + it('is skipped when only backend is true', () => { + const context = makeContext({ pluginJsonExtras: { backend: true } }); + datasourceDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/alerting.md')).toBe(false); + }); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/index.ts b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/index.ts new file mode 100644 index 0000000000..4f44a7f9f1 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/index.ts @@ -0,0 +1,31 @@ +import * as v from 'valibot'; +import type { Context } from '../../../context.js'; +import { + type ConditionalFilePredicate, + assertPluginType, + setupDocsScaffolding, + sourceContainsVariableSupport, +} from '../_docs-shared/setup.js'; + +export const schema = v.object({ + docsPath: v.optional(v.string(), 'docs'), +}); + +type Options = v.InferOutput; + +const CONDITIONAL_FILES: Record = { + 'template-variables.md': ({ basePath }) => sourceContainsVariableSupport(basePath), + 'annotations.md': ({ pluginJson }) => pluginJson.annotations === true, + 'alerting.md': ({ pluginJson }) => pluginJson.alerting === true && pluginJson.backend === true, +}; + +export default function datasourceDocs(context: Context, options: Options): Context { + assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' }); + return setupDocsScaffolding({ + context, + docsPath: options.docsPath, + templateBaseUrl: new URL('./templates/', import.meta.url), + codemodName: 'datasource-docs', + conditionalFiles: CONDITIONAL_FILES, + }); +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/alerting.md b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/alerting.md similarity index 100% rename from packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/alerting.md rename to packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/alerting.md diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/annotations.md b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/annotations.md similarity index 100% rename from packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/annotations.md rename to packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/annotations.md diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/configuration.md similarity index 100% rename from packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/configuration.md rename to packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/configuration.md diff --git a/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/index.md b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/index.md new file mode 100644 index 0000000000..69c3e8135a --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/index.md @@ -0,0 +1,27 @@ +--- +title: Overview +description: Learn about the {{pluginName}} plugin. +sidebar_position: 1 +--- + + + +Introduce {{pluginName}}: what the plugin does and what problem it solves for users. Keep it to one short paragraph, then expand in the sections below. Lead with the user-facing value, not implementation detail. + + + +## Features + + + +List the key capabilities of {{pluginName}} as a short bulleted list. Each bullet should describe a concrete user-facing capability, not a marketing claim. + + + +## Requirements + + + +List what users need before installing {{pluginName}}: minimum Grafana version, required data sources, network access, accounts, or feature flags. Be explicit about version constraints. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/query-editor.md similarity index 100% rename from packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/query-editor.md rename to packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/query-editor.md diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/template-variables.md b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/template-variables.md similarity index 100% rename from packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/template-variables.md rename to packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/template-variables.md diff --git a/packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/troubleshooting.md b/packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/troubleshooting.md similarity index 100% rename from packages/create-plugin/src/codemods/additions/scripts/catalog-docs/templates/datasource/docs/troubleshooting.md rename to packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/troubleshooting.md diff --git a/packages/create-plugin/src/codemods/additions/scripts/panel-docs/index.test.ts b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/index.test.ts new file mode 100644 index 0000000000..ee12a5322a --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/index.test.ts @@ -0,0 +1,98 @@ +import { existsSync } from 'node:fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Context } from '../../../context.js'; +import panelDocs from './index.js'; + +const { existsSync: realExistsSync } = await vi.importActual('node:fs'); + +vi.mock('node:fs', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + existsSync: vi.fn().mockImplementation(mod.existsSync), + }; +}); + +function makeContext(): Context { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', name: 'My Panel' })); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); + return context; +} + +describe('panel-docs codemod', () => { + beforeEach(() => { + vi.mocked(existsSync).mockImplementation(realExistsSync); + }); + + describe('type guard', () => { + it('errors when plugin.json type is datasource', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'X' })); + expect(() => panelDocs(context, { docsPath: 'docs' })).toThrow( + /only works on 'panel'.*type is 'datasource'.*datasource-docs/s + ); + }); + + it('errors when plugin.json type is app', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'app', name: 'X' })); + expect(() => panelDocs(context, { docsPath: 'docs' })).toThrow(/only works on 'panel'/); + }); + + it('errors when plugin.json type is unset', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ name: 'X' })); + expect(() => panelDocs(context, { docsPath: 'docs' })).toThrow(/type is 'unset'/); + }); + }); + + describe('generated files', () => { + it('creates all five panel docs files', () => { + const context = makeContext(); + panelDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('docs/index.md')).toBe(true); + expect(context.doesFileExist('docs/data-formats.md')).toBe(true); + expect(context.doesFileExist('docs/options.md')).toBe(true); + expect(context.doesFileExist('docs/examples.md')).toBe(true); + expect(context.doesFileExist('docs/troubleshooting.md')).toBe(true); + }); + + it('uses the expected H2s in each panel file', () => { + const context = makeContext(); + panelDocs(context, { docsPath: 'docs' }); + expect(context.getFile('docs/data-formats.md') ?? '').toContain('## Supported data shape'); + expect(context.getFile('docs/options.md') ?? '').toContain('## Panel options'); + expect(context.getFile('docs/examples.md') ?? '').toContain('## Basic example'); + expect(context.getFile('docs/troubleshooting.md') ?? '').toContain('## Common issues'); + }); + + it('wraps sections in agent-hint blocks', () => { + const context = makeContext(); + panelDocs(context, { docsPath: 'docs' }); + expect(context.getFile('docs/index.md') ?? '').toContain(''); + expect(context.getFile('docs/options.md') ?? '').toContain(''); + }); + + it('interpolates pluginName into the index page', () => { + const context = makeContext(); + panelDocs(context, { docsPath: 'docs' }); + expect(context.getFile('docs/index.md') ?? '').toContain('My Panel'); + }); + + it('writes the validate-docs workflow', () => { + const context = makeContext(); + panelDocs(context, { docsPath: 'docs' }); + expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true); + }); + + it('bumps the build-plugin ref in release.yml', () => { + const context = makeContext(); + panelDocs(context, { docsPath: 'docs' }); + expect(context.getFile('.github/workflows/release.yml') ?? '').toContain( + 'grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step' + ); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/panel-docs/index.ts b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/index.ts new file mode 100644 index 0000000000..ea81bb2c3a --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/index.ts @@ -0,0 +1,19 @@ +import * as v from 'valibot'; +import type { Context } from '../../../context.js'; +import { assertPluginType, setupDocsScaffolding } from '../_docs-shared/setup.js'; + +export const schema = v.object({ + docsPath: v.optional(v.string(), 'docs'), +}); + +type Options = v.InferOutput; + +export default function panelDocs(context: Context, options: Options): Context { + assertPluginType(context, { expectedType: 'panel', codemodName: 'panel-docs' }); + return setupDocsScaffolding({ + context, + docsPath: options.docsPath, + templateBaseUrl: new URL('./templates/', import.meta.url), + codemodName: 'panel-docs', + }); +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/data-formats.md b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/data-formats.md new file mode 100644 index 0000000000..19d8c1c82e --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/data-formats.md @@ -0,0 +1,27 @@ +--- +title: Data formats +description: Learn what data formats the {{pluginName}} panel accepts. +sidebar_position: 2 +--- + + + +Describe the data shape {{pluginName}} needs from its data source queries. Be specific about field types and structure - this is the contract dashboard authors rely on when building queries for the panel. + + + +## Supported data shape + + + +Describe the data frame shape {{pluginName}} expects: time series with N numeric fields, table with specific columns, single numeric value, geographic coordinates, log lines, etc. Name the required field types (time, number, string, boolean) and any minimum/maximum field counts. + + + +## Field mapping + + + +Explain which incoming field plays which role in the visualization - for example "the first time-typed field is the x-axis, all numeric fields become series". If {{pluginName}} lets users explicitly pick field roles in the editor, document those controls here. Remove the section if mapping is fully automatic and not user-configurable. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/examples.md b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/examples.md new file mode 100644 index 0000000000..b6472e00e3 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/examples.md @@ -0,0 +1,27 @@ +--- +title: Examples +description: Worked examples of the {{pluginName}} panel. +sidebar_position: 4 +--- + + + +Show concrete examples of {{pluginName}} configured for real use cases. Express each example in `dashboard.json` panel format - the JSON shape used inside a dashboard's `panels` array, including `targets` and `options`. Pair each example with a screenshot of the rendered visualization. + + + +## Basic example + + + +Provide the minimum viable {{pluginName}} configuration: the smallest `panels[]` entry that produces a meaningful visualization. Include the `targets` array with a sample query and any required `options`. Pair with a screenshot of the result. + + + +## Common variations + + + +Show 1-3 alternate configurations that cover the most common ways dashboard authors use {{pluginName}} - different data shapes, option combinations, or display modes. Each variation should be a complete `panels[]` entry plus a screenshot. Explain in one line what each variation demonstrates. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/index.md b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/index.md new file mode 100644 index 0000000000..69c3e8135a --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/index.md @@ -0,0 +1,27 @@ +--- +title: Overview +description: Learn about the {{pluginName}} plugin. +sidebar_position: 1 +--- + + + +Introduce {{pluginName}}: what the plugin does and what problem it solves for users. Keep it to one short paragraph, then expand in the sections below. Lead with the user-facing value, not implementation detail. + + + +## Features + + + +List the key capabilities of {{pluginName}} as a short bulleted list. Each bullet should describe a concrete user-facing capability, not a marketing claim. + + + +## Requirements + + + +List what users need before installing {{pluginName}}: minimum Grafana version, required data sources, network access, accounts, or feature flags. Be explicit about version constraints. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/options.md b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/options.md new file mode 100644 index 0000000000..2416d6250b --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/options.md @@ -0,0 +1,35 @@ +--- +title: Options +description: Reference for {{pluginName}} panel options. +sidebar_position: 3 +--- + + + +Reference the custom options {{pluginName}} adds to the panel editor - the controls that go beyond Grafana's built-in standard options (unit, decimals, thresholds, value mappings, field overrides, data links). Do not redocument those framework features here; Grafana already covers them. + + + +## Panel options + + + +List every option in the main {{pluginName}} editor section. For each option, give the field label as it appears in the UI, the type (toggle, select, slider, color picker, etc.), the default, and what it controls in the visualization. Group related options under H3 subsections where it improves scannability. + + + +## Tooltip options + + + +Describe the tooltip-related options {{pluginName}} exposes (hover behavior, what fields the tooltip shows, formatting). Remove this section if the panel does not have its own tooltip configuration. + + + +## Legend options + + + +Describe the legend-related options {{pluginName}} exposes (placement, values shown, calculations). Remove this section if the panel does not have its own legend configuration. + + diff --git a/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/troubleshooting.md b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/troubleshooting.md new file mode 100644 index 0000000000..084b3ff820 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/troubleshooting.md @@ -0,0 +1,19 @@ +--- +title: Troubleshooting +description: Troubleshoot the {{pluginName}} panel. +sidebar_position: 5 +--- + + + +List the most common problems users hit with {{pluginName}}, with diagnostic steps and resolutions for each. Use one H2 per real failure mode (for example "Panel shows no data", "Wrong field selected", "Rendering errors") so users can scan for their specific symptom. + + + +## Common issues + + + +Replace this placeholder with one H2 per concrete failure mode users encounter with {{pluginName}}. For each issue, describe the symptom users see, the most likely root cause, and the steps to resolve it. Focus on real, observed failures - not generic advice. + + From ed9dd132b2c6f809ee2752f6be49349447bbe003 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 25 May 2026 15:44:45 +0200 Subject: [PATCH 08/13] cleanup --- .../scripts/_docs-shared/setup.test.ts | 70 +++++ .../additions/scripts/_docs-shared/setup.ts | 280 +++++++++++++++++- .../_docs-shared/templates/README-suffix.txt | 52 ++++ .../AGENTS/skills/review-plugin-docs/SKILL.md | 78 +++++ .../skills/validate-plugin-docs/SKILL.md | 70 +++++ .../AGENTS/skills/write-plugin-docs/SKILL.md | 79 +++++ .../templates/agent/docs/AGENTS.md | 127 ++++++++ .../scripts/datasource-docs/index.test.ts | 228 ++++++++++++-- .../scripts/datasource-docs/index.ts | 15 + .../skills/bootstrap-plugin-docs/SKILL.md | 118 ++++++++ .../datasource-docs/templates/docs/README.txt | 57 ++++ .../templates/docs/alerting.md | 2 +- .../templates/docs/annotations.md | 2 +- .../templates/docs/dashboard.md | 19 ++ .../datasource-docs/templates/docs/macros.md | 27 ++ .../templates/docs/template-variables.md | 2 +- .../templates/docs/troubleshooting.md | 2 +- .../scripts/panel-docs/index.test.ts | 62 +++- .../additions/scripts/panel-docs/index.ts | 13 +- .../panel-docs/templates/docs/README.txt | 42 +++ packages/plugin-docs-cli/src/bin/run.ts | 15 + .../src/commands/affected.command.test.ts | 106 +++++++ .../src/commands/affected.command.ts | 70 +++++ 23 files changed, 1494 insertions(+), 42 deletions(-) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/README-suffix.txt create mode 100644 packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/review-plugin-docs/SKILL.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/validate-plugin-docs/SKILL.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/write-plugin-docs/SKILL.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/docs/AGENTS.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/agent/.config/AGENTS/skills/bootstrap-plugin-docs/SKILL.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/README.txt create mode 100644 packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/dashboard.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/datasource-docs/templates/docs/macros.md create mode 100644 packages/create-plugin/src/codemods/additions/scripts/panel-docs/templates/docs/README.txt create mode 100644 packages/plugin-docs-cli/src/commands/affected.command.test.ts create mode 100644 packages/plugin-docs-cli/src/commands/affected.command.ts diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts index 2ee3fdb1d1..c7e5e489f7 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts @@ -5,10 +5,12 @@ import { pathToFileURL } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Context } from '../../../context.js'; import { + assertAgentLoop, assertPluginType, type ConditionalFilePredicate, setupDocsScaffolding, sourceContainsVariableSupport, + sourceIsSqlDatasource, } from './setup.js'; // capture the real existsSync before mocking so we can delegate to it in beforeEach @@ -270,6 +272,18 @@ describe('_docs-shared/setup', () => { }); }); + describe('assertAgentLoop', () => { + it('throws a friendly message when loop is undefined', () => { + expect(() => assertAgentLoop(undefined)).toThrow( + /Missing required flag: --agent-loop[\s\S]*--agent-loop=claude[\s\S]*--agent-loop=none/ + ); + }); + + it.each(['claude', 'codex', 'cursor', 'none'] as const)('accepts %s as a valid loop', (loop) => { + expect(() => assertAgentLoop(loop)).not.toThrow(); + }); + }); + describe('assertPluginType', () => { it('returns the parsed plugin.json when the type matches', () => { const context = makeContext({ pluginJson: { type: 'datasource', name: 'X' } }); @@ -343,4 +357,60 @@ describe('_docs-shared/setup', () => { expect(sourceContainsVariableSupport(basePath)).toBe(false); }); }); + + describe('sourceIsSqlDatasource', () => { + function writeFile(basePath: string, relPath: string, content: string): void { + const target = join(basePath, relPath); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, content); + } + + it.each([ + ['Go sqlds versioned import', 'datasource.go', 'import "github.com/grafana/sqlds/v3"\n'], + ['Go sqlds unversioned import', 'datasource.go', 'import "github.com/grafana/sqlds"\n'], + ['TS @grafana/sql double quotes', 'datasource.ts', 'import { SqlQueryEditorLazy } from "@grafana/sql";\n'], + ['TS @grafana/sql single quotes', 'datasource.ts', "import { SQLQuery } from '@grafana/sql';\n"], + ])('returns true for %s in src/', (_, file, content) => { + const basePath = makeTempPluginDir({ [file]: content }); + expect(sourceIsSqlDatasource(basePath)).toBe(true); + }); + + it('walks pkg/ in addition to src/', () => { + const basePath = mkdtempSync(join(tmpdir(), 'docs-shared-test-')); + tempDirs.push(basePath); + writeFile(basePath, 'pkg/plugin/datasource.go', 'import "github.com/grafana/sqlds/v3"\n'); + expect(sourceIsSqlDatasource(basePath)).toBe(true); + }); + + it('walks nested directories under src/', () => { + const basePath = makeTempPluginDir({ + 'backend/handler.go': 'import (\n "github.com/grafana/sqlds"\n)\n', + }); + expect(sourceIsSqlDatasource(basePath)).toBe(true); + }); + + it('returns false when sqlds is mentioned only in a comment-like string', () => { + const basePath = makeTempPluginDir({ + 'datasource.go': '// the sqlds library github.com/grafana/sqlds is not used here\nfunc main() {}\n', + }); + expect(sourceIsSqlDatasource(basePath)).toBe(false); + }); + + it('returns false when only the Go stdlib database/sql is imported', () => { + const basePath = makeTempPluginDir({ + 'datasource.go': 'import "database/sql"\n', + }); + expect(sourceIsSqlDatasource(basePath)).toBe(false); + }); + + it('returns false when no SQL signal is present', () => { + const basePath = makeTempPluginDir({ 'datasource.ts': 'export class DataSource {}\n' }); + expect(sourceIsSqlDatasource(basePath)).toBe(false); + }); + + it('returns false when neither src/ nor pkg/ exists', () => { + const basePath = makeTempPluginDir(); + expect(sourceIsSqlDatasource(basePath)).toBe(false); + }); + }); }); diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts index 51e0655d28..a4261d65bf 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts @@ -7,6 +7,13 @@ import { additionsDebug, addDependenciesToPackageJson } from '../../../utils.js' // TODO: replace with stable tag once plugin-actions PR #219 merges const REQUIRED_BUILD_PLUGIN_REF = 'eriksundell/plugin-docs-build-step'; +export interface PluginJsonInclude { + type?: string; + name?: string; + path?: string; + [key: string]: unknown; +} + export interface PluginJson { type?: string; name?: string; @@ -14,21 +21,60 @@ export interface PluginJson { annotations?: boolean; alerting?: boolean; backend?: boolean; + includes?: PluginJsonInclude[]; [key: string]: unknown; } export type ConditionalFilePredicate = (ctx: { pluginJson: PluginJson; basePath: string }) => boolean; +// supported agent loops. `none` disables all agent-related scaffolding. +export type AgentLoop = 'claude' | 'codex' | 'cursor' | 'none'; + +// maps each non-none loop to its conventional skills directory. +const LOOP_SKILL_TARGET: Record, string> = { + claude: '.claude/skills', + codex: '.agents/skills', + cursor: '.cursor/skills', +}; + +// the path under the codemod's agent/ template tree where the canonical +// (unrouted) skill files live. The codemod rewrites this prefix to the +// loop-specific directory at scaffold time. +const SKILLS_TEMPLATE_PREFIX = '.config/AGENTS/skills/'; + +// computes the destination path for an agent template file given the chosen +// loop. Skill files are rerouted from the codemod's internal canonical path to +// the loop's conventional skills directory. Everything else (docs/AGENTS.md +// today) passes through unchanged. +function targetPathForLoop(relPath: string, agentLoop: Exclude): string | undefined { + if (relPath.startsWith(SKILLS_TEMPLATE_PREFIX)) { + return `${LOOP_SKILL_TARGET[agentLoop]}/${relPath.slice(SKILLS_TEMPLATE_PREFIX.length)}`; + } + return relPath; +} + export interface DocsSetupOptions { context: Context; docsPath: string; templateBaseUrl: URL; codemodName: string; conditionalFiles?: Record; + /** + * Which AI agent loop to scaffold support for. Controls whether docs/AGENTS.md + * and the per-loop skills are written. + * + * Defaults to `none` if omitted - in which case NO agent files are written + * (including `docs/AGENTS.md` and any skills). + * + * The `agent/` template subtree maps to the target plugin like this: + * agent/docs/AGENTS.md -> docs/AGENTS.md + * agent/.config/AGENTS/skills//SKILL.md -> //SKILL.md + */ + agentLoop?: AgentLoop; } export function setupDocsScaffolding(opts: DocsSetupOptions): Context { - const { context, docsPath, templateBaseUrl, codemodName, conditionalFiles = {} } = opts; + const { context, docsPath, templateBaseUrl, codemodName, conditionalFiles = {}, agentLoop = 'none' } = opts; // step 1: early exit if the docs directory already exists on disk if (existsSync(join(context.basePath, docsPath))) { @@ -65,26 +111,94 @@ export function setupDocsScaffolding(opts: DocsSetupOptions): Context { // step 4: add docs:serve and docs:validate npm scripts addDocsScripts(context); - // step 5: copy template files to docs folder + // step 5: copy template files to docs folder (includes README.txt) copyDocsTemplates(context, templateBaseUrl, docsPath, pluginName, pluginJson, conditionalFiles); - // step 6: copy validate-docs workflow from the shared templates folder (same dir as this file) + // step 6: append the AI-workflow section to the docs README when an agent loop is selected + if (agentLoop !== 'none') { + appendAgentSuffixToReadme(context, docsPath, pluginName); + } + + // step 7: copy validate-docs workflow from the shared templates folder (same dir as this file) upsertFile(context, '.github/workflows/validate-docs.yml', readSharedTemplate('workflows/validate-docs.yml')); - // step 7: bump build-plugin version in release.yml + // step 8: bump build-plugin version in release.yml bumpBuildPluginVersion(context); - // step 8: print next-steps summary - console.log(` -Next steps: - - Fill in the stub docs under ${docsPath}/ with your plugin's actual content - - Run \`npm run docs:serve\` to preview the docs locally - - Run \`npm run docs:validate\` to check for issues before pushing -`); + // step 9: optionally scaffold AI authoring assistance (AGENTS.md, skills) + let agentAssistanceAdded = false; + if (agentLoop !== 'none') { + agentAssistanceAdded = copyAgentTemplates(context, templateBaseUrl, pluginName, agentLoop); + if (agentAssistanceAdded) { + appendMultiPageDocsSectionToInstructions(context, docsPath); + } + } + + // step 9: print next-steps summary + const readmePresent = existsSync(join(context.basePath, 'README.md')); + printNextSteps({ docsPath, agentAssistanceAdded, readmePresent, agentLoop }); return context; } +function printNextSteps(opts: { + docsPath: string; + agentAssistanceAdded: boolean; + readmePresent: boolean; + agentLoop: AgentLoop; +}): void { + const { docsPath, agentAssistanceAdded, readmePresent, agentLoop } = opts; + const lines = ['', 'Next steps:']; + if (agentAssistanceAdded && readmePresent) { + lines.push( + ` - Ask an AI agent to run the \`bootstrap-plugin-docs\` skill - it will mine your README and source files into the new ${docsPath}/ stubs` + ); + } else if (agentAssistanceAdded) { + lines.push( + ` - Ask an AI agent to run the \`write-plugin-docs\` skill on each stub under ${docsPath}/ (read ${docsPath}/AGENTS.md first)` + ); + } else { + lines.push(` - Fill in the stub docs under ${docsPath}/ with your plugin's actual content`); + } + // the `agentLoop !== 'none'` check is required for TypeScript narrowing + // (LOOP_SKILL_TARGET is keyed by Exclude). At runtime + // `agentAssistanceAdded` already implies a non-none loop. + if (agentAssistanceAdded && agentLoop !== 'none') { + lines.push(` - Skills are available under ${LOOP_SKILL_TARGET[agentLoop]}/`); + } + lines.push(' - Run `npm run docs:serve` to preview the docs locally'); + lines.push(' - Run `npm run docs:validate` to check for issues before pushing'); + lines.push(''); + console.log(lines.join('\n')); +} + +// throws a helpful error message when the user omits `--agent-loop`. Use this +// at the top of each codemod's entrypoint, before calling setupDocsScaffolding. +// +// Note: this is a manual check rather than a valibot schema constraint because +// valibot's `v.object` raises a generic "Invalid key: Expected X but received +// undefined" error for missing required fields that can't be customized at the +// field-schema level. Pairs with `agentLoop: v.optional(v.union(...))` in each +// codemod's schema (no default, just optional). +export function assertAgentLoop(loop: AgentLoop | undefined): asserts loop is AgentLoop { + if (loop !== undefined) { + return; + } + throw new Error( + [ + 'Missing required flag: --agent-loop', + '', + "This codemod can ship a set of AI skills that help author plugin docs and keep them aligned with Grafana's documentation standards.", + '', + 'Pick how you want the skills wired up:', + ' --agent-loop=claude install skills under .claude/skills/ (Claude Code)', + ' --agent-loop=codex install skills under .agents/skills/ (OpenAI Codex)', + ' --agent-loop=cursor install skills under .cursor/skills/ (Cursor)', + ' --agent-loop=none skip the AI skills entirely (just scaffold the docs files)', + ].join('\n') + ); +} + // parses src/plugin.json from the context and verifies its `type` matches the // expected value. Throws a helpful error otherwise. Returns the parsed object // so callers don't have to reparse. @@ -156,6 +270,60 @@ function walkForMatch(dir: string): boolean { return false; } +const SQL_SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.go']; +const SQL_PATTERNS = [ + // Go import of sqlds (any major version, with or without /vN suffix) + /["']github\.com\/grafana\/sqlds(\/v\d+)?["']/, + // TS/JS import of the shared SQL frontend library + /from\s+["']@grafana\/sql["']/, +]; + +// detects whether the plugin source uses one of the shared SQL libraries +// (`github.com/grafana/sqlds` on the backend or `@grafana/sql` on the +// frontend). Walks both `src/` and `pkg/` since Go backend code is sometimes +// kept under `pkg/`. Returns true on the first match. +export function sourceIsSqlDatasource(basePath: string): boolean { + for (const dir of ['src', 'pkg']) { + const root = join(basePath, dir); + if (!existsSync(root)) { + continue; + } + if (walkForSqlMatch(root)) { + return true; + } + } + return false; +} + +function walkForSqlMatch(dir: string): boolean { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') { + continue; + } + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (walkForSqlMatch(fullPath)) { + return true; + } + continue; + } + if (!SQL_SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + continue; + } + const content = readFileSync(fullPath, 'utf-8'); + if (SQL_PATTERNS.some((re) => re.test(content))) { + return true; + } + } + return false; +} + function copyDocsTemplates( context: Context, templateBaseUrl: URL, @@ -205,6 +373,25 @@ function readSharedTemplate(relativePath: string): string { return readFileSync(templatePath, 'utf-8'); } +// appends the shared AI-workflow suffix to the docs README. No-op if the +// README is missing from Context (e.g. a codemod chose not to scaffold one) +// or if the suffix is already present. +function appendAgentSuffixToReadme(context: Context, docsPath: string, pluginName: string): void { + const readmePath = `${docsPath}/README.txt`; + const existing = context.getFile(readmePath); + if (existing === undefined) { + additionsDebug(`${readmePath} not found in context; skipping agent-workflow suffix`); + return; + } + const suffix = readSharedTemplate('README-suffix.txt').replaceAll('{{pluginName}}', pluginName); + if (existing.includes('AI authoring assistance')) { + additionsDebug(`${readmePath} already contains the AI authoring section, skipping`); + return; + } + const trailingNewline = existing.endsWith('\n') ? '' : '\n'; + context.updateFile(readmePath, `${existing}${trailingNewline}${suffix}`); +} + function bumpBuildPluginVersion(context: Context): void { const releaseYmlContent = context.getFile('.github/workflows/release.yml'); if (!releaseYmlContent) { @@ -222,6 +409,77 @@ function bumpBuildPluginVersion(context: Context): void { context.updateFile('.github/workflows/release.yml', updated); } +// scaffolds AI authoring assistance: docs/AGENTS.md plus the four skills under +// the loop-specific skills path (.claude/skills/, .agents/skills/ or +// .cursor/skills/). Skill files are stored under .config/AGENTS/skills/ in the +// codemod's internal template tree and get re-routed to the loop's +// conventional path at scaffold time. +// +// Reads from TWO template directories: +// 1. The codemod-specific `/agent/` (plugin-type-specific +// skills like bootstrap-plugin-docs, plus any per-codemod overrides). +// 2. The shared `_docs-shared/templates/agent/` (the generic AGENTS.md and +// the type-agnostic skills: write-plugin-docs, review-plugin-docs, +// validate-plugin-docs). +// +// Codemod-specific files win when both directories contain the same path, +// since we iterate the codemod-specific dir first and refuse to overwrite +// existing Context entries. +// +// returns true if at least one file was written. existing files are never +// overwritten - the user may have customized them. +function copyAgentTemplates( + context: Context, + templateBaseUrl: URL, + pluginName: string, + agentLoop: Exclude +): boolean { + const codemodAgentDir = fileURLToPath(new URL('./agent', templateBaseUrl)); + const sharedAgentDir = fileURLToPath(new URL('./templates/agent', import.meta.url)); + let wroteSomething = false; + for (const agentTemplateDir of [codemodAgentDir, sharedAgentDir]) { + if (!existsSync(agentTemplateDir)) { + continue; + } + for (const filePath of listFilesRecursively(agentTemplateDir)) { + const relPath = filePath.slice(agentTemplateDir.length + 1); + const targetPath = targetPathForLoop(relPath, agentLoop); + if (targetPath === undefined) { + continue; + } + if (context.doesFileExist(targetPath)) { + additionsDebug(`${targetPath} already exists, skipping`); + continue; + } + const content = readFileSync(filePath, 'utf-8').replaceAll('{{pluginName}}', pluginName); + context.addFile(targetPath, content); + wroteSomething = true; + } + } + return wroteSomething; +} + +const MULTI_PAGE_DOCS_MARKER = '## Multi-page docs'; + +// appends a "Multi-page docs" section to the plugin's +// .config/AGENTS/instructions.md so agents working on src/ remember to keep +// docs in sync. idempotent - if the section is already there, does nothing. +function appendMultiPageDocsSectionToInstructions(context: Context, docsPath: string): void { + const targetPath = '.config/AGENTS/instructions.md'; + const existing = context.getFile(targetPath); + if (existing === undefined) { + additionsDebug(`${targetPath} not found; skipping multi-page docs section append`); + return; + } + if (existing.includes(MULTI_PAGE_DOCS_MARKER)) { + additionsDebug(`${targetPath} already contains a Multi-page docs section, skipping`); + return; + } + const trailingNewline = existing.endsWith('\n') ? '' : '\n'; + const appended = `${existing}${trailingNewline}\n${MULTI_PAGE_DOCS_MARKER}\n\nThis plugin uses multi-page docs in \`${docsPath}/\`. Read \`${docsPath}/AGENTS.md\` before authoring or editing pages.\n\n- Docs and source can drift apart if one changes without the other. When modifying a file under \`src/\`, check whether the pages under \`${docsPath}/\` still describe the file accurately and update them in the same change.\n- Four Agent Skills cover the docs workflows: \`bootstrap-plugin-docs\` (one-shot brownfield migration), \`write-plugin-docs\` (per-page authoring), \`review-plugin-docs\` (plugin-specific review), \`validate-plugin-docs\` (validate → fix loop). They are scaffolded under your agent loop's skills folder (\`.claude/skills/\`, \`.agents/skills/\` or \`.cursor/skills/\`).\n`; + context.updateFile(targetPath, appended); +} + function addDocsScripts(context: Context): void { const packageJsonContent = context.getFile('package.json'); if (!packageJsonContent) { diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/README-suffix.txt b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/README-suffix.txt new file mode 100644 index 0000000000..ee92b3098d --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/README-suffix.txt @@ -0,0 +1,52 @@ + +AI authoring assistance +----------------------- + +You scaffolded this plugin with AI agent skills. Four skills live under your +agent loop's skills folder (.claude/skills/, .agents/skills/ or +.cursor/skills/, depending on the loop you picked): + + bootstrap-plugin-docs - one-shot, plugin-wide. Mines README and source + files into the scaffolded stubs, detects soft + features and proposes additional pages. Run this + first. + write-plugin-docs - per-page. Fills a stub page or updates an + existing one. Reads the source files implied by + the page title and section hints. + review-plugin-docs - reviews docs files for frontmatter compliance, + style rules, agent-hint cleanup and factual + alignment with source. + validate-plugin-docs - runs `npm run docs:validate --json`, applies + category-based fixes, iterates up to 3 times. + +The authoring conventions the skills enforce are documented in docs/AGENTS.md. + +Recommended workflow +-------------------- + +Once, when starting: + /bootstrap-plugin-docs + +This walks through every scaffolded stub plus any README content and fills +them in. Greenfield plugins (no README content) work too - the skill leans +on source-code analysis and prompts you for non-source-backed context. + +When adding a new feature later: + 1. Code the feature first (source files, plugin.json edits). + 2. If the feature warrants a new doc page (e.g. you added RBAC roles and + want to document them): + /write-plugin-docs /.md + The skill catalogs conventional filenames in its step 4 - check there + for the right name. If there is no conventional fit, pick a kebab-case + filename and use it. + 3. Update existing pages whose scope changed (e.g. you added a config + field, so configuration.md needs an update): + /write-plugin-docs /configuration.md + 4. Review the diff: + /review-plugin-docs + 5. Validate before pushing: + /validate-plugin-docs + +When updating an existing page: + Run /write-plugin-docs directly. The skill re-reads the + source files implied by the page title and updates content to match. diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/review-plugin-docs/SKILL.md b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/review-plugin-docs/SKILL.md new file mode 100644 index 0000000000..b4f5daaf4a --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/review-plugin-docs/SKILL.md @@ -0,0 +1,78 @@ +--- +name: review-plugin-docs +description: Plugin-specific review of docs files against frontmatter requirements, agent-hint cleanup, style rules, and factual alignment with source files. Reports findings without auto-editing. Use before merging changes that touch docs/**. +--- + +# Review Plugin Docs + +> **Path conventions:** Throughout this skill, `/` refers to the docs folder configured in `src/plugin.json` via the `docsPath` field (default: `docs`). Read that value before following any step that touches a path inside the docs folder. + +## Usage + +``` +/review-plugin-docs [page-path] +``` + +Run from the plugin root. Without a page path, reviews every doc page changed in the current git diff. With a page path, reviews just that file. + +This skill is plugin-specific and intentionally narrow. For a heavyweight pass (link validation, Vale style linting, multi-agent technical review) use a dedicated docs review tool. + +## Steps + +1. Determine the target file set: + + ```bash + if [ -n "$TARGET" ]; then + FILES="$TARGET" + else + FILES=$(git diff --name-only HEAD / 2>/dev/null | grep '\.md$') + if [ -z "$FILES" ]; then + FILES=$(git diff --name-only --cached / 2>/dev/null | grep '\.md$') + fi + fi + ``` + + If still empty, ask the user which page to review. + +2. Read `/AGENTS.md` for the style rules and frontmatter schema. Keep them in context for the rest of the review. + +3. For each target file: + + **Frontmatter checks:** + - `title`, `description`, `sidebar_position` are all present. + - `description` is a single concise sentence (not a paragraph). + + **Agent-hint cleanup:** + - No `` or `` markers remain. If any are found, flag them and propose stripping them. + + **Style rule check** (against the 13 rules in `/AGENTS.md`): + - Present tense, not future "will". + - Active voice. + - Second person "you", not first person "we/our". + - No filler adjectives (easy, simple, just, obviously). + - No exclamation marks in body text. + - Bold for UI elements; code formatting for commands/paths/values. + - Descriptive link text. + - "refer to" not "see" for links. + - Sentence case in headings. + - No `--` (double hyphens) - use `-` or `—`. + + **Factual alignment:** + - For each documented field, type or behaviour, locate it in `src/` via grep or path inspection. The page title and section hints suggest where to look (the bootstrap-plugin-docs skill's page catalog lists conventional source-to-page mappings for the plugin type). Flag anything invented. + + **Forbidden content:** + - No MDX or React components. + - No references to one-click installation. + - No internal-only URLs (anything on `*.grafana-ops.net`, `*.staging.*` etc.). + - No invented example data unless clearly labelled as illustrative. + +4. Report findings in this format, grouped by file: + + ``` + [/configuration.md] + - Line 12: STYLE - "users will see" should be "users see" (present tense). + - Line 34: FACTUAL - documented field `region` not found in src/components/ConfigEditor.tsx. Verify or remove. + - Line 88: HINT - leftover agent-hint block. Strip lines 86-90. + ``` + +5. Do not auto-edit. Surface every finding in chat. If the user says "apply them", proceed page by page. After applying, run `npm run docs:validate -- --json` and report any remaining issues. diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/validate-plugin-docs/SKILL.md b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/validate-plugin-docs/SKILL.md new file mode 100644 index 0000000000..1816299ee0 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/validate-plugin-docs/SKILL.md @@ -0,0 +1,70 @@ +--- +name: validate-plugin-docs +description: Runs `npm run docs:validate --json` and applies category-based auto-fixes to plugin docs files, iterating up to 3 times. Use after authoring or editing docs to clean up structural validation errors before pushing. +--- + +# Validate Plugin Docs + +> **Path conventions:** Throughout this skill, `/` refers to the docs folder configured in `src/plugin.json` via the `docsPath` field (default: `docs`). Read that value before following any step that touches a path inside the docs folder. + +## Usage + +``` +/validate-plugin-docs +``` + +Run from the plugin root. Loops validate → fix → validate until clean or stuck. + +## Steps + +1. Run the validator with JSON output: + + ```bash + npm run docs:validate -- --json + ``` + + The output is a single JSON document of shape: + + ```json + { + "valid": false, + "diagnostics": [ + { + "severity": "error", + "rule": "frontmatter-required-fields", + "file": "/x.md", + "message": "...", + "line": 3 + } + ] + } + ``` + +2. If `valid` is `true`, report success and exit. + +3. Group diagnostics by `rule`. Apply fixes per category: + + | Rule category | Fix the agent attempts | + | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | + | `frontmatter-*` | Add missing required field, correct type, remove unknown field. Required fields are `title`, `description`, `sidebar_position`. | + | `has-markdown-files` / `*-index` / `*-naming` | Create missing required page from the same scaffold conventions used by `create-plugin add datasource-docs`. Rename misplaced files. | + | `no-spaces-in-names` / `valid-file-naming` | Rename to kebab-case. | + | `no-raw-html` / `no-script-tags` | Remove offending HTML except the `` blocks (which the parser ignores). | + | `image-refs-relative` / `internal-links-relative` | Fix path. | + | `internal-links-resolve` | Suggest closest existing page; ask the user before changing semantics. | + | `referenced-images-exist` / `no-orphaned-images` | Flag for the user. Do not generate images. Do not delete referenced images. | + | `max-image-size` / `max-total-images-size` / `max-data-uri-size` | Flag for the user. Suggest re-encoding to PNG or splitting into multiple pages. Do not auto-resize. | + | `manifest-*` | Manifest is auto-generated downstream. Surface as a bug; do not patch. | + +4. After applying a round of fixes, re-run `npm run docs:validate -- --json`. + +5. Repeat steps 2-4 up to **3 iterations total**. If validation is still failing after 3 rounds, stop and report: + - Which diagnostics remain unfixed. + - Why the heuristic could not fix them. + - What the user should do next. + +6. When clean, run a final sanity check: + ```bash + npm run docs:validate + ``` + This runs in strict mode; the exit code is what CI checks. A zero exit here means the docs ship. diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/write-plugin-docs/SKILL.md b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/write-plugin-docs/SKILL.md new file mode 100644 index 0000000000..003a98ed84 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/write-plugin-docs/SKILL.md @@ -0,0 +1,79 @@ +--- +name: write-plugin-docs +description: Authors or updates a single plugin docs page. Consults the source files implied by the page's section hints and title to keep content grounded in current code, and applies the project's style rules from `/AGENTS.md`. Use when filling a stub page, adding a new page, or updating docs after a code change. +--- + +# Write Plugin Docs + +> **Path conventions:** Throughout this skill, `/` refers to the docs folder configured in `src/plugin.json` via the `docsPath` field (default: `docs`). Read that value before following any step that touches a path inside the docs folder. + +## Usage + +``` +/write-plugin-docs +``` + +Run from the plugin root. The page path can be an existing doc page (e.g. `/query-editor.md`) or a topic the user wants documented for the first time (e.g. "IAM user setup"). + +Read `/AGENTS.md` before writing. It defines the frontmatter schema, voice rules, agent-hint protocol and the common page patterns catalog. + +## Branch A: filling or updating an existing page + +1. Read the target page. Identify every ` ... ` block. Each sits under a section heading and scopes that section. + +2. Identify the source files that back the page. The page title and the agent-hint text imply the right source - the bootstrap-plugin-docs skill's page catalog lists conventional source-to-page mappings for the plugin type. Use grep, file listing and the import graph to find them. + +3. Read those source files. Only document fields, behaviours and types visible in the files you read. Do not invent. + +4. Read 1-2 sibling pages in the same `/` folder for tone and structure consistency. + +5. Fill each section in place. For each section: + - Use the hint as scope guidance. + - Apply the style rules from `/AGENTS.md`. + - Use real code examples drawn from source, not invented. + - For numbered-step procedures, write a clean ordered list with one action per step. + - For example queries, use the `dashboard.json` `targets` JSON shape, not free prose. + +6. Strip every `agent-hint` block after the corresponding section is written. The block format is exactly: + + ```html + + ... + + ``` + +7. Validate: + + ```bash + npm run docs:validate -- --json + ``` + + Fix any errors reported. Re-run until clean. + +8. Optional visual check: + ```bash + npm run docs:serve + ``` + +## Branch B: creating a brand-new page + +1. Check the page catalog in the `bootstrap-plugin-docs` skill. If the requested topic matches a catalogued pattern, use that filename and scope. + +2. **Check whether the new topic is tightly coupled to an existing page.** If so, restructure as a folder rather than creating a flat sibling. For example, if you are creating `service-account-setup.md` and `/configuration.md` already exists with overlapping scope, restructure both into: + - `/configuration/index.md` (the existing configuration content moves here) + - `/configuration/service-account-setup.md` (the new page) + + Confirm the restructure with the user before moving the existing file. Refer to "Group closely-coupled pages into folders" in `/AGENTS.md` for the full convention. + +3. If no catalogued pattern matches and no existing page warrants grouping, propose: + - A `kebab-case` filename under `/`. + - A `sidebar_position` value placing the page where it belongs in nav order. + + Confirm both with the user before creating the file. + +4. Create the file with the frontmatter schema from `/AGENTS.md`: + - `title`, `description`, `sidebar_position` are required. + +5. Write the content. For non-source-backed pages (external setup, prerequisites, conceptual overviews), use the user's context plus authoritative external documentation. Always link to upstream documentation for steps in third-party systems rather than mirroring them in full. For source-backed pages, follow the read-source step from Branch A before writing. + +6. Validate as in Branch A step 7. diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/docs/AGENTS.md b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/docs/AGENTS.md new file mode 100644 index 0000000000..a17e11ac5e --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/docs/AGENTS.md @@ -0,0 +1,127 @@ +--- +name: plugin docs authoring guide +description: Guides how AI agents author, maintain, review and validate the multi-page docs for {{pluginName}} +--- + +# Plugin docs authoring + +> **Path conventions:** This file lives at the path configured in `src/plugin.json` via the `docsPath` field (default: `docs`). Throughout this file and the bundled skills, `/` refers to that same folder. Substitute the actual value wherever you see it. + +This folder contains the multi-page documentation for the **{{pluginName}}** plugin. Pages here are rendered by `@grafana/plugin-docs-parser` and published to `grafana.com/grafana/plugins//docs/`. + +If you are filling in stub pages, maintaining docs alongside code changes or adding new pages, read this file first. Four skills, scaffolded under your agent loop's skills folder (`.claude/skills/`, `.agents/skills/` or `.cursor/skills/`), cover the common workflows. + +## Frontmatter schema + +Every page starts with a YAML frontmatter block: + +```yaml +--- +title: Configuration # required - the H1 / nav label +description: Learn how to ... # required - short page meta, used for SEO and sidebar previews +sidebar_position: 2 # required - controls ordering in the docs nav (lower = earlier) +deprecated: false # optional - hides the page from nav when true +--- +``` + +## Filesystem conventions + +- One Markdown file per page. Filename in `kebab-case`. +- Nested folders become nested URLs (Docusaurus-style). `/setup/iam.md` becomes `...//setup/iam`. +- No MDX, no React components, no `