From bf2736ca5527b231847c718d283909b12ad5e702 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 4 Feb 2026 09:20:17 +0100 Subject: [PATCH 01/30] add dev script --- packages/plugin-docs-renderer/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-docs-renderer/package.json b/packages/plugin-docs-renderer/package.json index 91868abfaf..ad18210fb4 100644 --- a/packages/plugin-docs-renderer/package.json +++ b/packages/plugin-docs-renderer/package.json @@ -26,6 +26,7 @@ }, "scripts": { "build": "rollup -c ../../rollup.config.ts --configPlugin esbuild", + "dev": "rollup -c ../../rollup.config.ts --configPlugin esbuild --watch", "lint": "eslint --cache ./src", "lint:fix": "npm run lint -- --fix", "lint:package": "publint", From 36f068370546be8a7d3cd64d96cd4a51fc47ad97 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 4 Feb 2026 09:45:09 +0100 Subject: [PATCH 02/30] add serve cmd --- packages/plugin-docs-renderer/src/bin/run.ts | 77 +++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/packages/plugin-docs-renderer/src/bin/run.ts b/packages/plugin-docs-renderer/src/bin/run.ts index 5504e298e7..1b74d3a6b9 100644 --- a/packages/plugin-docs-renderer/src/bin/run.ts +++ b/packages/plugin-docs-renderer/src/bin/run.ts @@ -1,53 +1,60 @@ #!/usr/bin/env node import { access } from 'node:fs/promises'; +import { resolve } from 'node:path'; import minimist from 'minimist'; -import { loadDocsFolder } from '../loader.js'; -import { parseMarkdown } from '../parser.js'; +import { startServer } from '../server.js'; + +async function commandServe(docsPath: string, port: number, liveReload: boolean) { + console.log('Starting development server...\n'); + + try { + startServer({ + docsPath, + port, + liveReload, + }); + } catch (error) { + if (error instanceof Error) { + console.error('Error starting server:', error.message); + } else { + console.error('Error starting server:', error); + } + process.exit(1); + } +} async function main() { - const argv = minimist(process.argv.slice(2)); - // get docs path from command line arguments or use default (./docs) - const docsPath = argv._[0] || './docs'; + const argv = minimist(process.argv.slice(2), { + boolean: ['reload'], + string: ['port'], + alias: { + p: 'port', + r: 'reload', + }, + default: { + port: '3001', + reload: false, + }, + }); + + const docsPath = resolve(argv._[1] || './docs'); // check if the path exists try { await access(docsPath); } catch { - console.error(`Path not found: ${docsPath}`); - console.error('Usage: npx @grafana/plugin-docs-renderer [docs-folder]'); - console.error('Example: npx @grafana/plugin-docs-renderer ./docs'); + console.error(`Error: Path not found: ${docsPath}`); process.exit(1); } - // load and parse documentation - try { - const { manifest, files } = await loadDocsFolder(docsPath); - - // parse each markdown file - const pages: Record; html: string }> = {}; - for (const [filePath, content] of Object.entries(files)) { - const parsed = parseMarkdown(content); - pages[filePath] = { - frontmatter: parsed.frontmatter, - html: parsed.html, - }; - } - - // output to console - const result = { - manifest, - pages, - }; - - console.log(JSON.stringify(result, null, 2)); - } catch (error) { - if (error instanceof Error) { - console.error('Error processing documentation:', error.message); - } else { - console.error('Error processing documentation:', error); - } + // parse port + const port = parseInt(argv.port, 10); + if (isNaN(port) || port < 1 || port > 65535) { + console.error(`Error: Invalid port: ${argv.port}`); process.exit(1); } + + await commandServe(docsPath, port, argv.reload); } main(); From 9d7a3512084937f55de9f46fc6580f4377b1791a Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 4 Feb 2026 09:46:20 +0100 Subject: [PATCH 03/30] add very basic dev server --- packages/plugin-docs-renderer/src/index.ts | 4 + packages/plugin-docs-renderer/src/server.ts | 172 ++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 packages/plugin-docs-renderer/src/server.ts diff --git a/packages/plugin-docs-renderer/src/index.ts b/packages/plugin-docs-renderer/src/index.ts index 5a73e29cf6..f88cd2e52d 100644 --- a/packages/plugin-docs-renderer/src/index.ts +++ b/packages/plugin-docs-renderer/src/index.ts @@ -8,3 +8,7 @@ export type { ParsedMarkdown } from './parser.js'; // export loader functions export { loadDocsFolder } from './loader.js'; export type { LoadedDocs } from './loader.js'; + +// export server functions +export { startServer } from './server.js'; +export type { ServerOptions } from './server.js'; diff --git a/packages/plugin-docs-renderer/src/server.ts b/packages/plugin-docs-renderer/src/server.ts new file mode 100644 index 0000000000..3ec96fee3f --- /dev/null +++ b/packages/plugin-docs-renderer/src/server.ts @@ -0,0 +1,172 @@ +import express, { type Express, type Request, type Response } from 'express'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { watch } from 'chokidar'; +import { parseMarkdown } from './parser.js'; +import type { Manifest, Page } from './types.js'; + +export interface ServerOptions { + docsPath: string; + port?: number; + liveReload?: boolean; +} + +function generateNavItems(pages: Page[]): string { + return pages.map((page) => `
  • ${page.title}
  • `).join('\n'); +} + +/** + * Generates an HTML template for a documentation page. + */ +function generatePageHTML(title: string, content: string, manifest: Manifest, liveReload: boolean): string { + // generate a simple navigation from manifest + const navItems = generateNavItems(manifest.pages); + + // optional live reload script + const reloadScript = liveReload + ? ` + + ` + : ''; + + return ` + + + + + + ${title} - ${manifest.title} + + + +
    +
    + ${content} +
    + ${reloadScript} + + + `.trim(); +} + +/** + * Starts a development server for previewing plugin documentation. + * + * @param options - Server configuration options + * @returns The Express app instance + */ +export function startServer(options: ServerOptions): Express { + const { docsPath, port = 3000, liveReload = false } = options; + + const app = express(); + let lastModified = Date.now(); + + // setup file watcher + const watcher = watch([join(docsPath, '**/*.md'), join(docsPath, 'manifest.json')], { + ignoreInitial: true, + }); + + watcher.on('change', async (path) => { + console.log(`File changed: ${path}`); + lastModified = Date.now(); + console.log('āœ“ Change detected'); + }); + + // serve static assets (images, etc.) + app.use('/img', express.static(join(docsPath, 'img'))); + app.use('/assets', express.static(join(docsPath, 'assets'))); + app.use('/images', express.static(join(docsPath, 'images'))); + + // live reload endpoint (if enabled) + if (liveReload) { + app.get('/__reload__', (req: Request, res: Response) => { + const clientTime = parseInt(req.query.t as string, 10) || 0; + if (lastModified > clientTime) { + res.status(205).send(); // 205 = Reset Content (signals reload) + } else { + res.status(204).send(); // 204 = No Content (no changes) + } + }); + } + + // helper to find page by slug in manifest + function findPageBySlug(slug: string, pages: Page[]): string | null { + for (const page of pages) { + if (page.slug === slug) { + return page.file; + } + if (page.children) { + const found = findPageBySlug(slug, page.children); + if (found) { + return found; + } + } + } + return null; + } + + // serve documentation pages + app.get('/:slug?', async (req: Request, res: Response) => { + try { + // load manifest from disk + const manifestPath = join(docsPath, 'manifest.json'); + const manifestContent = await readFile(manifestPath, 'utf-8'); + const manifest: Manifest = JSON.parse(manifestContent); + + // default to first page in manifest + const slug = req.params.slug || manifest.pages[0]?.slug; + if (!slug) { + res.status(404).send('No pages found in manifest'); + return; + } + + // find the file for this slug + const fileName = findPageBySlug(slug, manifest.pages); + if (!fileName) { + res.status(404).send('Page not found'); + return; + } + + // read and parse the markdown file + const filePath = join(docsPath, fileName); + const fileContent = await readFile(filePath, 'utf-8'); + const parsed = parseMarkdown(fileContent); + + const title = (parsed.frontmatter.title as string) || slug; + const html = generatePageHTML(title, parsed.html, manifest, liveReload); + res.send(html); + } catch (error) { + console.error('Error serving page:', error); + res.status(500).send('Internal server error'); + } + }); + + // start the server + app.listen(port, () => { + console.log(`\nšŸ“„ Plugin Documentation Server`); + console.log(`āœ“ Serving: ${docsPath}`); + console.log(`āœ“ URL: http://localhost:${port}`); + console.log(`āœ“ Live reload: ${liveReload ? 'enabled' : 'disabled'}`); + console.log(`\nšŸ” Watching for changes...\n`); + }); + + return app; +} From 2c9cfad6bf21811ed1c03cd95ff5ff4da777b31c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 5 Feb 2026 07:54:27 +0100 Subject: [PATCH 04/30] fix argument parsing --- packages/plugin-docs-renderer/src/bin/run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-docs-renderer/src/bin/run.ts b/packages/plugin-docs-renderer/src/bin/run.ts index 1b74d3a6b9..edcdab4a19 100644 --- a/packages/plugin-docs-renderer/src/bin/run.ts +++ b/packages/plugin-docs-renderer/src/bin/run.ts @@ -37,7 +37,7 @@ async function main() { }, }); - const docsPath = resolve(argv._[1] || './docs'); + const docsPath = resolve(argv._[0] || './docs'); // check if the path exists try { From e30ce36efe771704a34ea58256fee41ec8ce7241 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 5 Feb 2026 08:10:45 +0100 Subject: [PATCH 05/30] add server/api test --- .../src/__fixtures__/test-docs/advanced.md | 3 + .../src/__fixtures__/test-docs/guide.md | 7 + .../src/__fixtures__/test-docs/home.md | 8 ++ .../src/__fixtures__/test-docs/img/test.png | 1 + .../src/__fixtures__/test-docs/manifest.json | 23 ++++ .../plugin-docs-renderer/src/server.test.ts | 130 ++++++++++++++++++ 6 files changed, 172 insertions(+) create mode 100644 packages/plugin-docs-renderer/src/__fixtures__/test-docs/advanced.md create mode 100644 packages/plugin-docs-renderer/src/__fixtures__/test-docs/guide.md create mode 100644 packages/plugin-docs-renderer/src/__fixtures__/test-docs/home.md create mode 100644 packages/plugin-docs-renderer/src/__fixtures__/test-docs/img/test.png create mode 100644 packages/plugin-docs-renderer/src/__fixtures__/test-docs/manifest.json create mode 100644 packages/plugin-docs-renderer/src/server.test.ts diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/advanced.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/advanced.md new file mode 100644 index 0000000000..9652de2cc8 --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/advanced.md @@ -0,0 +1,3 @@ +# Advanced Topics + +This covers advanced topics. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/guide.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/guide.md new file mode 100644 index 0000000000..069356d512 --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/guide.md @@ -0,0 +1,7 @@ +--- +title: User Guide +--- + +# User Guide + +This is a guide page. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/home.md b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/home.md new file mode 100644 index 0000000000..afe70d0eaf --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/home.md @@ -0,0 +1,8 @@ +--- +title: Home Page +description: Welcome to the test docs +--- + +# Welcome + +This is the home page of the test documentation. diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/img/test.png b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/img/test.png new file mode 100644 index 0000000000..cd69803d6a --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/img/test.png @@ -0,0 +1 @@ +test-image diff --git a/packages/plugin-docs-renderer/src/__fixtures__/test-docs/manifest.json b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/manifest.json new file mode 100644 index 0000000000..fe31a9bd5c --- /dev/null +++ b/packages/plugin-docs-renderer/src/__fixtures__/test-docs/manifest.json @@ -0,0 +1,23 @@ +{ + "version": "1.0", + "title": "Test Plugin Documentation", + "pages": [ + { + "title": "Home", + "slug": "home", + "file": "home.md" + }, + { + "title": "Guide", + "slug": "guide", + "file": "guide.md", + "children": [ + { + "title": "Advanced", + "slug": "advanced", + "file": "advanced.md" + } + ] + } + ] +} diff --git a/packages/plugin-docs-renderer/src/server.test.ts b/packages/plugin-docs-renderer/src/server.test.ts new file mode 100644 index 0000000000..95283b31e0 --- /dev/null +++ b/packages/plugin-docs-renderer/src/server.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import request from 'supertest'; +import { join } from 'node:path'; +import type { Express } from 'express'; +import { startServer } from './server.js'; + +describe('startServer', () => { + const testDocsPath = join(__dirname, '__fixtures__', 'test-docs'); + let app: Express; + + afterEach(() => { + // cleanup: close any open connections + if (app) { + const server = (app as any).server; + if (server?.close) { + server.close(); + } + } + }); + + it('should serve the homepage (first page in manifest)', async () => { + app = startServer({ docsPath: testDocsPath, port: 0 }); + + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Home Page - Test Plugin Documentation'); + expect(response.text).toContain('

    Welcome

    '); + expect(response.text).toContain('This is the home page of the test documentation.'); + }); + + it('should serve a specific page by slug', async () => { + app = startServer({ docsPath: testDocsPath, port: 0 }); + + const response = await request(app).get('/guide'); + + expect(response.status).toBe(200); + expect(response.text).toContain('User Guide - Test Plugin Documentation'); + expect(response.text).toContain('

    User Guide

    '); + expect(response.text).toContain('This is a guide page.'); + }); + + it('should serve a nested page by slug', async () => { + app = startServer({ docsPath: testDocsPath, port: 0 }); + + const response = await request(app).get('/advanced'); + + expect(response.status).toBe(200); + expect(response.text).toContain('

    Advanced Topics

    '); + expect(response.text).toContain('This covers advanced topics.'); + }); + + it('should return 404 for non-existent page', async () => { + app = startServer({ docsPath: testDocsPath, port: 0 }); + + const response = await request(app).get('/non-existent'); + + expect(response.status).toBe(404); + expect(response.text).toContain('Page not found'); + }); + + it('should include navigation links', async () => { + app = startServer({ docsPath: testDocsPath, port: 0 }); + + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).toContain('