diff --git a/package-lock.json b/package-lock.json index 47cdefe38d..f0e1e69fd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15758,7 +15758,6 @@ }, "node_modules/cssfilter": { "version": "0.0.10", - "dev": true, "license": "MIT" }, "node_modules/cssnano": { @@ -22603,7 +22602,6 @@ "version": "16.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz", "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==", - "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -34730,7 +34728,6 @@ }, "node_modules/xss": { "version": "1.0.15", - "dev": true, "license": "MIT", "dependencies": { "commander": "^2.20.3", @@ -34745,7 +34742,6 @@ }, "node_modules/xss/node_modules/commander": { "version": "2.20.3", - "dev": true, "license": "MIT" }, "node_modules/xtend": { @@ -35159,7 +35155,9 @@ "github-slugger": "^1.5.0", "gray-matter": "^4.0.3", "hast-util-to-html": "^9.0.0", - "minimist": "^1.2.8" + "marked": "^16.3.0", + "minimist": "^1.2.8", + "xss": "^1.0.15" }, "bin": { "plugin-docs-cli": "dist/bin/run.js" diff --git a/packages/plugin-docs-cli/package.json b/packages/plugin-docs-cli/package.json index dfb27a9678..6b25417453 100644 --- a/packages/plugin-docs-cli/package.json +++ b/packages/plugin-docs-cli/package.json @@ -54,7 +54,9 @@ "github-slugger": "^1.5.0", "gray-matter": "^4.0.3", "hast-util-to-html": "^9.0.0", - "minimist": "^1.2.8" + "marked": "^16.3.0", + "minimist": "^1.2.8", + "xss": "^1.0.15" }, "devDependencies": { "@types/debug": "^4.1.12", diff --git a/packages/plugin-docs-cli/src/__fixtures__/test-docs/index.md b/packages/plugin-docs-cli/src/__fixtures__/test-docs/index.md new file mode 100644 index 0000000000..fd8250a03a --- /dev/null +++ b/packages/plugin-docs-cli/src/__fixtures__/test-docs/index.md @@ -0,0 +1,7 @@ +--- +title: Overview +description: Landing page for the test docs +sidebar_position: 0 +--- + +Welcome to the test docs. See the [guide](./guide) for details, or visit [Grafana](https://grafana.com). diff --git a/packages/plugin-docs-cli/src/__fixtures__/test-readme/README.md b/packages/plugin-docs-cli/src/__fixtures__/test-readme/README.md new file mode 100644 index 0000000000..9e128e8cbe --- /dev/null +++ b/packages/plugin-docs-cli/src/__fixtures__/test-readme/README.md @@ -0,0 +1,15 @@ +# Test Plugin + +This is the plugin readme, shown on the Overview tab. + +## Getting started with `grafana-cli` + +Install the plugin and open Grafana. + +### Prerequisites + +Run Grafana v10 or later. + +## Screenshots + +See below. diff --git a/packages/plugin-docs-cli/src/__fixtures__/unsafe-slug-docs/index.md b/packages/plugin-docs-cli/src/__fixtures__/unsafe-slug-docs/index.md new file mode 100644 index 0000000000..7f925ebe85 --- /dev/null +++ b/packages/plugin-docs-cli/src/__fixtures__/unsafe-slug-docs/index.md @@ -0,0 +1,6 @@ +--- +title: Overview +description: Landing page for the unsafe-slug fixture +--- + +Landing page for the unsafe-slug fixture. diff --git a/packages/plugin-docs-cli/src/bin/run.ts b/packages/plugin-docs-cli/src/bin/run.ts index d096cf9798..5942b5813c 100644 --- a/packages/plugin-docs-cli/src/bin/run.ts +++ b/packages/plugin-docs-cli/src/bin/run.ts @@ -2,7 +2,7 @@ import { stat } from 'node:fs/promises'; import minimist from 'minimist'; import createDebug from 'debug'; -import { resolvePluginJson } from '../utils/utils.plugin.js'; +import { readmeCandidates, resolvePluginJson } from '../utils/utils.plugin.js'; import { serve } from '../commands/serve.command.js'; import { buildDocs } from '../commands/build.command.js'; import { validateCommand } from '../commands/validate.command.js'; @@ -69,7 +69,7 @@ async function main() { reload: false, }, }); - await serve(serveArgv, docsPath, pluginType); + await serve(serveArgv, docsPath, pluginType, readmeCandidates()); break; } case 'build': { diff --git a/packages/plugin-docs-cli/src/commands/build.command.test.ts b/packages/plugin-docs-cli/src/commands/build.command.test.ts index 6416757f72..429ccaf31d 100644 --- a/packages/plugin-docs-cli/src/commands/build.command.test.ts +++ b/packages/plugin-docs-cli/src/commands/build.command.test.ts @@ -28,9 +28,10 @@ describe('build', () => { const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')); expect(manifest.version).toBe('1'); - expect(manifest.pages).toHaveLength(4); - expect(manifest.pages[0].title).toBe('Home Page'); - expect(manifest.pages[0].slug).toBe('home'); + expect(manifest.pages).toHaveLength(5); + expect(manifest.pages[0].slug).toBe('index'); + expect(manifest.pages[1].title).toBe('Home Page'); + expect(manifest.pages[1].slug).toBe('home'); }); it('should inline frontmatter and content into manifest.json, self-contained', async () => { @@ -39,7 +40,7 @@ describe('build', () => { const manifestPath = join(tmpDir, 'dist', 'docs', 'manifest.json'); const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')); - const home = manifest.pages[0]; + const home = manifest.pages.find((p: { slug: string }) => p.slug === 'home'); expect(home.frontmatter).toEqual({ title: 'Home Page', description: 'Welcome to the test docs', diff --git a/packages/plugin-docs-cli/src/commands/serve.command.ts b/packages/plugin-docs-cli/src/commands/serve.command.ts index a7fa4fa24a..19a8e4f245 100644 --- a/packages/plugin-docs-cli/src/commands/serve.command.ts +++ b/packages/plugin-docs-cli/src/commands/serve.command.ts @@ -4,7 +4,12 @@ import { startServer } from '../server/server.js'; const debug = createDebug('plugin-docs-cli:serve'); -export const serve = async (argv: minimist.ParsedArgs, docsPath: string, pluginType?: string) => { +export const serve = async ( + argv: minimist.ParsedArgs, + docsPath: string, + pluginType?: string, + readmePaths?: string[] +) => { debug('Serve command invoked with args: %O', argv); // parse port @@ -19,6 +24,7 @@ export const serve = async (argv: minimist.ParsedArgs, docsPath: string, pluginT try { await startServer({ docsPath, + readmePaths, port, liveReload, pluginType, diff --git a/packages/plugin-docs-cli/src/scanner.test.ts b/packages/plugin-docs-cli/src/scanner.test.ts index a5692a21ee..1ef3b04126 100644 --- a/packages/plugin-docs-cli/src/scanner.test.ts +++ b/packages/plugin-docs-cli/src/scanner.test.ts @@ -12,7 +12,7 @@ describe('scanDocsFolder', () => { expect(result.manifest).toBeDefined(); expect(result.manifest.version).toBe('1'); expect(result.manifest.title).toBe('Plugin Documentation'); - expect(result.manifest.pages).toHaveLength(4); // home, guide, advanced, config + expect(result.manifest.pages).toHaveLength(5); // index, home, guide, advanced, config }); it('should sort root index.md first even when sidebar_position is not set', async () => { @@ -27,28 +27,30 @@ describe('scanDocsFolder', () => { const result = await scanDocsFolder(testDocsPath); const pages = result.manifest.pages; - expect(pages[0].title).toBe('Home Page'); - expect(pages[0].slug).toBe('home'); - expect(pages[1].title).toBe('User Guide'); - expect(pages[1].slug).toBe('guide'); - expect(pages[2].title).toBe('Advanced Topics'); - expect(pages[2].slug).toBe('advanced'); + expect(pages[0].slug).toBe('index'); + expect(pages[1].title).toBe('Home Page'); + expect(pages[1].slug).toBe('home'); + expect(pages[2].title).toBe('User Guide'); + expect(pages[2].slug).toBe('guide'); + expect(pages[3].title).toBe('Advanced Topics'); + expect(pages[3].slug).toBe('advanced'); }); it('should generate slugs from file paths', async () => { const result = await scanDocsFolder(testDocsPath); const pages = result.manifest.pages; - expect(pages[0].slug).toBe('home'); - expect(pages[1].slug).toBe('guide'); - expect(pages[2].slug).toBe('advanced'); + expect(pages[0].slug).toBe('index'); + expect(pages[1].slug).toBe('home'); + expect(pages[2].slug).toBe('guide'); + expect(pages[3].slug).toBe('advanced'); }); it('should load file contents into memory (frontmatter stripped)', async () => { const result = await scanDocsFolder(testDocsPath); expect(result.files).toBeDefined(); - expect(Object.keys(result.files)).toHaveLength(6); // includes nested config files + index + expect(Object.keys(result.files)).toHaveLength(7); // index, home, guide, advanced, config/index, config/settings, config/database expect(result.files['home.md']).toContain('# Welcome'); expect(result.files['home.md']).not.toContain('---'); }); @@ -56,25 +58,26 @@ describe('scanDocsFolder', () => { it('should attach frontmatter and content to each page in the manifest', async () => { const result = await scanDocsFolder(testDocsPath); - const home = result.manifest.pages[0]; - expect(home.frontmatter).toEqual({ + const home = result.manifest.pages.find((p: Page) => p.slug === 'home'); + expect(home?.frontmatter).toEqual({ title: 'Home Page', description: 'Welcome to the test docs', sidebar_position: 1, }); - expect(home.content).toContain('# Welcome'); - expect(home.content).not.toContain('---'); + expect(home?.content).toContain('# Welcome'); + expect(home?.content).not.toContain('---'); // page.content matches what the flat files map holds for the same file - expect(home.content).toBe(result.files[home.file]); + expect(home?.content).toBe(result.files[home!.file]); }); it('should include file reference in page object', async () => { const result = await scanDocsFolder(testDocsPath); const pages = result.manifest.pages; - expect(pages[0].file).toBe('home.md'); - expect(pages[1].file).toBe('guide.md'); - expect(pages[2].file).toBe('advanced.md'); + expect(pages[0].file).toBe('index.md'); + expect(pages[1].file).toBe('home.md'); + expect(pages[2].file).toBe('guide.md'); + expect(pages[3].file).toBe('advanced.md'); }); it('should extract h2/h3 headings into page objects', async () => { @@ -115,8 +118,8 @@ describe('scanDocsFolder', () => { const unsafeSlugPath = join(__dirname, '__fixtures__', 'unsafe-slug-docs'); const result = await scanDocsFolder(unsafeSlugPath); - expect(result.manifest.pages).toHaveLength(1); - expect(result.manifest.pages[0].slug).toBe('home'); + expect(result.manifest.pages).toHaveLength(2); + expect(result.manifest.pages.find((p: Page) => p.title === 'Unsafe Slug Test')?.slug).toBe('home'); }); describe('nested directories', () => { diff --git a/packages/plugin-docs-cli/src/server/nav.test.ts b/packages/plugin-docs-cli/src/server/nav.test.ts new file mode 100644 index 0000000000..ae413b5d70 --- /dev/null +++ b/packages/plugin-docs-cli/src/server/nav.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from 'vitest'; +import type { Page } from '@grafana/plugin-docs-parser'; +import { + DOCS_BASE, + docPageHref, + docsLandingPage, + findDocAncestors, + findDocPage, + firstRenderablePage, + resolveDocHref, + toDocsNav, +} from './nav.js'; + +const page = (title: string, slug: string, file: string, extra: Partial = {}): Page => ({ + title, + slug, + file, + ...extra, +}); + +const manifestPages = (): Page[] => [ + page('Overview', 'index', 'index.md'), + page('Guide', 'guide', 'guide.md', { + headings: [ + { level: 2, text: 'Intro', id: 'intro' }, + { level: 3, text: 'Details', id: 'details' }, + { level: 4, text: 'Too deep', id: 'too-deep' }, + ], + }), + // a folder without an index.md is a category node with no file of its own + page('Options', 'options', '', { + children: [ + page('Legend', 'options/legend', 'options/legend.md'), + page('Colors', 'options/colors', 'options/colors.md'), + ], + }), + page('Configuration', 'config', 'config/index.md', { + children: [page('Settings', 'config/settings', 'config/settings.md')], + }), + page('Renamed', 'custom-slug', 'renamed.md'), +]; + +describe('docPageHref', () => { + it('maps the index page to the docs root', () => { + expect(docPageHref('index', DOCS_BASE)).toBe('/docs'); + }); + + it('puts every other page under the docs root', () => { + expect(docPageHref('config/settings', DOCS_BASE)).toBe('/docs/config/settings'); + }); + + it('encodes each segment but keeps the separators', () => { + expect(docPageHref('my page/ünï', DOCS_BASE)).toBe('/docs/my%20page/%C3%BCn%C3%AF'); + }); + + it('defaults to DOCS_BASE', () => { + expect(docPageHref('guide')).toBe('/docs/guide'); + }); +}); + +describe('page lookups', () => { + it('finds nested pages by slug', () => { + expect(findDocPage(manifestPages(), 'options/colors')?.title).toBe('Colors'); + expect(findDocPage(manifestPages(), 'nope')).toBeNull(); + }); + + it('returns the landing page only when index has a file', () => { + expect(docsLandingPage(manifestPages())?.file).toBe('index.md'); + expect(docsLandingPage([page('Guide', 'guide', 'guide.md')])).toBeNull(); + expect(docsLandingPage([page('Overview', 'index', '')])).toBeNull(); + }); + + it('finds the first page with a file under a category', () => { + const options = findDocPage(manifestPages(), 'options')!; + expect(firstRenderablePage(options)?.slug).toBe('options/legend'); + expect( + firstRenderablePage(page('Empty', 'empty', '', { children: [page('Also empty', 'empty/x', '')] })) + ).toBeNull(); + }); + + it('returns the chain of pages down to a slug', () => { + expect(findDocAncestors(manifestPages(), 'config/settings').map((p) => p.slug)).toEqual([ + 'config', + 'config/settings', + ]); + expect(findDocAncestors(manifestPages(), 'guide').map((p) => p.slug)).toEqual(['guide']); + expect(findDocAncestors(manifestPages(), 'nope')).toEqual([]); + }); +}); + +describe('toDocsNav', () => { + it('lists pages in order with hrefs under the docs root', () => { + const { items } = toDocsNav(manifestPages()); + expect(items.map((item) => [item.label, item.href])).toEqual([ + ['Overview', '/docs'], + ['Guide', '/docs/guide'], + ['Options', '/docs/options/legend'], + ['Configuration', '/docs/config'], + ['Renamed', '/docs/custom-slug'], + ]); + }); + + it('points a category at its first page and does not list that page twice', () => { + const options = toDocsNav(manifestPages()).items.find((item) => item.label === 'Options'); + expect(options?.children?.map((child) => child.label)).toEqual(['Colors']); + }); + + it('keeps all children of a folder that has its own index page', () => { + const config = toDocsNav(manifestPages()).items.find((item) => item.label === 'Configuration'); + expect(config?.children?.map((child) => child.label)).toEqual(['Settings']); + }); + + it('drops categories with no page anywhere in them', () => { + const { items } = toDocsNav([page('Overview', 'index', 'index.md'), page('Empty', 'empty', '')]); + expect(items.map((item) => item.label)).toEqual(['Overview']); + }); + + it('keys h2 and h3 headings by the page href and ignores deeper levels', () => { + const { headingsByHref } = toDocsNav(manifestPages()); + expect(headingsByHref['/docs/guide']).toEqual([ + { id: 'intro', text: 'Intro', level: 2 }, + { id: 'details', text: 'Details', level: 3 }, + ]); + expect(headingsByHref['/docs']).toBeUndefined(); + }); +}); + +describe('resolveDocHref', () => { + const resolve = (href: string, currentFile = 'index.md') => + resolveDocHref(href, currentFile, manifestPages(), DOCS_BASE); + + it('resolves sibling links against the current page', () => { + expect(resolve('./guide')).toBe('/docs/guide'); + expect(resolve('guide')).toBe('/docs/guide'); + }); + + it('resolves ../ links from a nested page', () => { + expect(resolve('../guide', 'config/settings.md')).toBe('/docs/guide'); + expect(resolve('settings', 'config/index.md')).toBe('/docs/config/settings'); + }); + + it('uses the page slug, not the file name', () => { + expect(resolve('./renamed')).toBe('/docs/custom-slug'); + }); + + it('maps a folder link to its index page', () => { + expect(resolve('./config/')).toBe('/docs/config'); + expect(resolve('./')).toBe('/docs'); + }); + + it('keeps the query and fragment', () => { + expect(resolve('./guide?x=1#intro')).toBe('/docs/guide?x=1#intro'); + }); + + it('falls back to a path under the docs root for links to unknown pages', () => { + expect(resolve('./missing')).toBe('/docs/missing'); + }); + + it('accepts backslash separators in the current file path', () => { + expect(resolve('../guide', 'config\\settings.md')).toBe('/docs/guide'); + }); + + it('leaves absolute, scheme, protocol-relative and fragment-only links alone', () => { + for (const href of ['https://grafana.com', 'mailto:a@b.c', '//cdn.example.com/x', '/abs/path', '#intro']) { + expect(resolve(href)).toBe(href); + } + }); + + it('refuses encoded dot segments', () => { + expect(resolve('..%2f..%2fx')).toBe('..%2f..%2fx'); + }); +}); diff --git a/packages/plugin-docs-cli/src/server/nav.ts b/packages/plugin-docs-cli/src/server/nav.ts new file mode 100644 index 0000000000..932a8b0224 --- /dev/null +++ b/packages/plugin-docs-cli/src/server/nav.ts @@ -0,0 +1,214 @@ +import type { Heading, Page } from '@grafana/plugin-docs-parser'; + +// slug of the page served at the docs root. matches catalog-website's DOCS_INDEX_SLUG so authors +// see the same layout locally as on grafana.com. +export const DOCS_INDEX_SLUG = 'index'; + +// any `scheme:` url — http:, mailto:, data:, tel: … — is the author's own absolute target +const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; + +// synthetic origin used only to borrow the URL parser's relative-path resolution ('../' etc.) +const RESOLVER_ORIGIN = 'https://resolver.invalid/'; + +/** The docs root path (base for all doc urls). */ +export const DOCS_BASE = '/docs'; + +/** Preview href for a manifest page. The index page lives at the docs root, not at `/docs/index`. */ +export function docPageHref(pageSlug: string, docsBase: string = DOCS_BASE): string { + if (pageSlug === DOCS_INDEX_SLUG) { + return docsBase; + } + return `${docsBase}/${encodeDocSlug(pageSlug)}`; +} + +/** Percent-encodes a manifest slug's segments while leaving the `/` separators intact. */ +export function encodeDocSlug(pageSlug: string): string { + return pageSlug.split('/').map(encodeURIComponent).join('/'); +} + +/** Strip a trailing slash from a url path. */ +export function stripTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +} + +/** Depth-first lookup by manifest slug. */ +export function findDocPage(pages: Page[], slug: string): Page | null { + for (const page of pages) { + if (page.slug === slug) { + return page; + } + const found = page.children ? findDocPage(page.children, slug) : null; + if (found) { + return found; + } + } + return null; +} + +/** + * The page served at `/docs` — the docs landing page the author writes as `index.md`. + * Null when the manifest has none, in which case the Documentation tab is hidden. + */ +export function docsLandingPage(pages: Page[]): Page | null { + const index = findDocPage(pages, DOCS_INDEX_SLUG); + return index?.file ? index : null; +} + +/** + * First node in a subtree backed by a real file. A directory without an `index.md` yields a + * category node with `file: ''` — the nav points at its first real descendant instead. + */ +export function firstRenderablePage(page: Page): Page | null { + if (page.file) { + return page; + } + for (const child of page.children ?? []) { + const found = firstRenderablePage(child); + if (found) { + return found; + } + } + return null; +} + +/** Chain of manifest nodes from the docs root down to `pageSlug`, inclusive. */ +export function findDocAncestors(pages: Page[], pageSlug: string): Page[] { + const walk = (nodes: Page[], trail: Page[]): Page[] | null => { + for (const node of nodes) { + const next = [...trail, node]; + if (node.slug === pageSlug) { + return next; + } + const found = node.children ? walk(node.children, next) : null; + if (found) { + return found; + } + } + return null; + }; + + return walk(pages, []) ?? []; +} + +export interface NavHeading { + id: string; + text: string; + level: 2 | 3; +} + +export interface NavItem { + label: string; + href: string; + slug: string; + children?: NavItem[]; +} + +export interface DocsNav { + items: NavItem[]; + /** Build-time h2/h3 per page, keyed by the page's href with any trailing slash stripped. */ + headingsByHref: Record; +} + +/** + * Maps a manifest page tree onto the sidebar nav shape used by catalog-website. + * + * A category node (a directory without an `index.md`) borrows its first renderable descendant's + * href, and that descendant is pruned from the category's children so nothing appears twice. + */ +export function toDocsNav(pages: Page[], docsBase: string = DOCS_BASE): DocsNav { + const headingsByHref: Record = {}; + + const toItems = (nodes: Page[]): NavItem[] => + nodes.flatMap((page) => { + const target = firstRenderablePage(page); + if (!target) { + return []; + } + + const href = docPageHref(target.slug, docsBase); + const headings = navHeadings(target); + if (headings.length > 0) { + headingsByHref[stripTrailingSlash(href)] = headings; + } + + const children = page.file ? page.children : pruneNode(page.children, target); + const childItems = children?.length ? toItems(children) : []; + + return [ + { + label: page.title, + href, + slug: target.slug, + children: childItems.length ? childItems : undefined, + }, + ]; + }); + + return { items: toItems(pages), headingsByHref }; +} + +function pruneNode(nodes: Page[] | undefined, remove: Page): Page[] | undefined { + return nodes?.flatMap((node) => (node === remove ? [] : [{ ...node, children: pruneNode(node.children, remove) }])); +} + +function navHeadings(page: Page): NavHeading[] { + return (page.headings ?? []) + .filter((heading): heading is Heading & { level: 2 | 3 } => heading.level === 2 || heading.level === 3) + .map((heading) => ({ id: heading.id, text: heading.text, level: heading.level })); +} + +/** + * Turns a relative doc link into a preview href, matching catalog-website. + * + * The parser strips `.md` but leaves the link relative ('permissions', '../troubleshooting'), so + * it is resolved against the current file's directory and mapped through the manifest — a page's + * URL comes from its `slug`, which frontmatter can override. + */ +export function resolveDocHref(href: string, currentFile: string, pages: Page[], docsBase: string): string { + if (!href || href.startsWith('#') || href.startsWith('//') || href.startsWith('/') || SCHEME_RE.test(href)) { + return href; + } + + const [, path, suffix] = /^([^#?]*)([\s\S]*)$/.exec(href) ?? []; + if (!path) { + return href; + } + + const resolved = resolveRelativePath(path, currentFile); + if (resolved === null) { + return href; + } + + const page = findPageByFile(pages, resolved); + const base = page ? docPageHref(page.slug, docsBase) : resolved ? `${docsBase}/${encodeDocSlug(resolved)}` : docsBase; + return `${base}${suffix}`; +} + +function resolveRelativePath(path: string, currentFile: string): string | null { + const normalizedFile = currentFile.replace(/\\/g, '/'); + const dir = normalizedFile.includes('/') ? normalizedFile.replace(/\/[^/]*$/, '/') : ''; + try { + const url = new URL(path, new URL(dir, RESOLVER_ORIGIN)); + const decoded = decodeURIComponent(url.pathname).replace(/^\//, '').replace(/\/$/, ''); + if (decoded.split('/').some((segment) => segment === '.' || segment === '..')) { + return null; + } + return decoded; + } catch { + return null; + } +} + +function findPageByFile(pages: Page[], path: string): Page | null { + const candidates = [`${path}.md`, `${path}/index.md`]; + for (const page of pages) { + if (page.file && candidates.includes(page.file)) { + return page; + } + const found = page.children ? findPageByFile(page.children, path) : null; + if (found) { + return found; + } + } + return null; +} diff --git a/packages/plugin-docs-cli/src/server/server.test.ts b/packages/plugin-docs-cli/src/server/server.test.ts index fbf40f7df9..e967d7f058 100644 --- a/packages/plugin-docs-cli/src/server/server.test.ts +++ b/packages/plugin-docs-cli/src/server/server.test.ts @@ -1,13 +1,17 @@ import { describe, it, expect, afterEach } from 'vitest'; import request from 'supertest'; import { join } from 'node:path'; +import { mkdtemp, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import type { Express } from 'express'; import { startServer, type Server } from './server.js'; describe('startServer', () => { - const testDocsPath = join(__dirname, '..', '__fixtures__', 'test-docs'); - const unsafeSlugDocsPath = join(__dirname, '..', '__fixtures__', 'unsafe-slug-docs'); - const emptyContentDocsPath = join(__dirname, '..', '__fixtures__', 'empty-content-docs'); + const fixturesPath = join(__dirname, '..', '__fixtures__'); + const testDocsPath = join(fixturesPath, 'test-docs'); + const unsafeSlugDocsPath = join(fixturesPath, 'unsafe-slug-docs'); + const emptyContentDocsPath = join(fixturesPath, 'empty-content-docs'); + const testReadmePath = join(fixturesPath, 'test-readme', 'README.md'); let app: Express; let server: Server | null = null; @@ -18,188 +22,324 @@ describe('startServer', () => { } }); - it('should serve the homepage (first page in manifest)', async () => { - const result = await startServer({ docsPath: testDocsPath, port: 0 }); + it('should render the README on the Overview tab (/)', async () => { + const result = await startServer({ docsPath: testDocsPath, readmePaths: [testReadmePath], port: 0 }); server = result; app = result.app; const response = await request(app).get('/'); expect(response.status).toBe(200); - expect(response.text).toContain('Home Page - Plugin Documentation'); - expect(response.text).toContain('

Home Page

'); - expect(response.text).toContain('This is the home page of the test documentation.'); + expect(response.text).toContain('Overview - Plugin Documentation (local preview)'); + expect(response.text).toContain('

Test Plugin

'); + expect(response.text).toContain('This is the plugin readme'); }); - it('should serve a frontmatter-only page with an empty body, not 404', async () => { - const result = await startServer({ docsPath: emptyContentDocsPath, port: 0 }); + it('should pick up README changes without a restart, preferring the first candidate', async () => { + const root = await mkdtemp(join(tmpdir(), 'readme-live-')); + const srcReadme = join(root, 'src-README.md'); + const rootReadme = join(root, 'README.md'); + const result = await startServer({ docsPath: testDocsPath, readmePaths: [srcReadme, rootReadme], port: 0 }); + server = result; + app = result.app; + + expect((await request(app).get('/')).text).toContain('No README found'); + + await writeFile(rootReadme, '# Root readme\n'); + expect((await request(app).get('/')).text).toContain('

Root readme

'); + + await writeFile(srcReadme, '# Src readme\n'); + expect((await request(app).get('/')).text).toContain('

Src readme

'); + + await unlink(srcReadme); + const afterDelete = await request(app).get('/'); + expect(afterDelete.status).toBe(200); + expect(afterDelete.text).toContain('

Root readme

'); + }); + + it('should show a placeholder when no README is configured', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; const response = await request(app).get('/'); expect(response.status).toBe(200); - expect(response.text).toContain('Empty Body - Plugin Documentation'); + expect(response.text).toContain('No README found'); + }); + + it('should serve the docs landing page at /docs', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/docs'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Overview - Plugin Documentation (local preview)'); + expect(response.text).toContain('Welcome to the test docs'); }); - it('should serve a specific page by slug', async () => { + it('should serve a docs page at /docs/', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/guide'); + const response = await request(app).get('/docs/guide'); expect(response.status).toBe(200); - expect(response.text).toContain('User Guide - Plugin Documentation'); - expect(response.text).toContain('

User Guide

'); + expect(response.text).toContain('User Guide - Plugin Documentation (local preview)'); expect(response.text).toContain('This is a guide page.'); }); - it('should serve a nested page by slug', async () => { + it('should serve a nested docs page at /docs//', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/advanced'); + const response = await request(app).get('/docs/config/settings'); expect(response.status).toBe(200); - expect(response.text).toContain('

Advanced Topics

'); - expect(response.text).toContain('This covers advanced topics.'); + expect(response.text).toContain('Settings - Plugin Documentation (local preview)'); + expect(response.text).toContain('Configure your plugin settings'); }); - it('should return 404 for non-existent page', async () => { + it('should return 404 for /docs/index (the landing lives at /docs)', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/non-existent'); + const response = await request(app).get('/docs/index'); expect(response.status).toBe(404); - expect(response.text).toContain('Page not found'); }); - it('should include navigation links', async () => { + it('should return 404 for a non-existent docs page', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; + const response = await request(app).get('/docs/non-existent'); + + expect(response.status).toBe(404); + expect(response.text).toContain('Page not found'); + }); + + it('should hide the Documentation tab when the manifest has no landing page', async () => { + const noIndexDocsPath = await mkdtemp(join(tmpdir(), 'docs-no-index-')); + await writeFile(join(noIndexDocsPath, 'guide.md'), '---\ntitle: Guide\ndescription: A guide\n---\n\nGuide body.\n'); + const result = await startServer({ docsPath: noIndexDocsPath, readmePaths: [testReadmePath], port: 0 }); + server = result; + app = result.app; + const response = await request(app).get('/'); expect(response.status).toBe(200); - expect(response.text).toContain('class="docs-nav"'); - expect(response.text).toContain(' { + it('should render docs nav in the rail on /docs pages', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/img/test.png'); + const response = await request(app).get('/docs'); expect(response.status).toBe(200); - expect(response.type).toBe('image/png'); - expect(response.body).toBeDefined(); + expect(response.text).toContain('class="docs-rail"'); + expect(response.text).toContain('>Documentation<'); + expect(response.text).toContain(' { + it('should nest active-page headings under the active nav item', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/config/database'); + const response = await request(app).get('/docs/config/settings'); expect(response.status).toBe(200); - // page lives at config/database.md, so `img/db-config.png` resolves under /config/img/... - expect(response.text).toContain('src="/config/img/db-config.png"'); - // `../img/test.png` resolves up to the docs root - expect(response.text).toContain('src="/img/test.png"'); + expect(response.text).toContain('href="#general-settings"'); + expect(response.text).toContain('href="#display-name"'); + expect(response.text).toContain('href="#advanced-settings"'); }); - it('should not include live reload script by default', async () => { - const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: false }); + it('should render a breadcrumb on nested pages and none on the landing page', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/'); + const landing = await request(app).get('/docs'); + expect(landing.text).not.toContain('docs-breadcrumb'); - expect(response.status).toBe(200); - expect(response.text).not.toContain('__reload__'); - expect(response.text).not.toContain('location.reload()'); + const nested = await request(app).get('/docs/config/settings'); + expect(nested.text).toContain('docs-breadcrumb'); + expect(nested.text).toContain('>Documentation<'); + expect(nested.text).toContain('>Configuration<'); }); - it('should include live reload script when enabled', async () => { - const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: true }); + it('should still render docs content when the frontmatter body is empty', async () => { + const result = await startServer({ docsPath: emptyContentDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/'); + const response = await request(app).get('/docs'); expect(response.status).toBe(200); - expect(response.text).toContain('__reload__'); - expect(response.text).toContain('location.reload()'); + expect(response.text).toContain('Empty Body - Plugin Documentation (local preview)'); }); - it('should have live reload endpoint when enabled', async () => { - const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: true }); + it('should serve static assets under /docs', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/__reload__?t=0'); + const response = await request(app).get('/docs/img/test.png'); - // should return 204 (no changes) or 205 (reset content) - expect([204, 205]).toContain(response.status); + expect(response.status).toBe(200); + expect(response.type).toBe('image/png'); }); - it('should use frontmatter title when available', async () => { + it('should rewrite relative image srcs to /docs-scoped urls', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/home'); + const response = await request(app).get('/docs/config/database'); expect(response.status).toBe(200); - expect(response.text).toContain('Home Page - Plugin Documentation'); + expect(response.text).toContain('src="/docs/config/img/db-config.png"'); + expect(response.text).toContain('src="/docs/img/test.png"'); }); - it('should use frontmatter title from advanced page', async () => { + it('should resolve relative doc links to preview urls', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/advanced'); + // index.md links to ./guide, which lives at /docs/guide + const response = await request(app).get('/docs'); expect(response.status).toBe(200); - // advanced.md now has frontmatter with title - expect(response.text).toContain('Advanced Topics - Plugin Documentation'); + expect(response.text).toContain('href="/docs/guide"'); }); - it('should serve a nested directory child page', async () => { + it('should open external links in a new tab and keep internal links in place', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0 }); server = result; app = result.app; - const response = await request(app).get('/config/settings'); + const response = await request(app).get('/docs'); expect(response.status).toBe(200); - expect(response.text).toContain('Settings - Plugin Documentation'); - expect(response.text).toContain('

Settings

'); + expect(response.text).toContain('href="https://grafana.com" target="_blank" rel="noopener noreferrer"'); + expect(response.text).not.toMatch(/href="\/docs\/guide"[^>]*target="_blank"/); }); - it('should render headings in the table of contents sidebar', async () => { - const result = await startServer({ docsPath: testDocsPath, port: 0 }); + it('should use plain heading text for README "On this page" labels', async () => { + const result = await startServer({ docsPath: testDocsPath, readmePaths: [testReadmePath], port: 0 }); server = result; app = result.app; - // settings.md has h2 and h3 headings - const response = await request(app).get('/config/settings'); + const response = await request(app).get('/'); expect(response.status).toBe(200); - expect(response.text).toContain('class="docs-toc"'); - expect(response.text).toContain('href="#general-settings"'); - expect(response.text).toContain('href="#display-name"'); - expect(response.text).toContain('href="#advanced-settings"'); + expect(response.text).toMatch(/class="docs-toc-link docs-toc-link-h2">Getting started with grafana-cli'); + }); + + it('should strip scripts, event handlers and javascript: links from the README like gcom', async () => { + const readmeDir = await mkdtemp(join(tmpdir(), 'readme-unsafe-')); + const unsafeReadmePath = join(readmeDir, 'README.md'); + await writeFile( + unsafeReadmePath, + '# Plugin\n\n\n\n
x\n\n- [x] done\n' + ); + const result = await startServer({ docsPath: testDocsPath, readmePaths: [unsafeReadmePath], port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).not.toContain(''); + expect(response.text).not.toContain('onclick='); + expect(response.text).not.toContain('javascript:alert'); + expect(response.text).toContain(''); + }); + + it('should wrap tables in a horizontal scroll container', async () => { + const tableDocsPath = await mkdtemp(join(tmpdir(), 'docs-table-')); + await writeFile( + join(tableDocsPath, 'index.md'), + '---\ntitle: Overview\ndescription: Table page\n---\n\n| a | b |\n| - | - |\n| 1 | 2 |\n' + ); + const result = await startServer({ docsPath: tableDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/docs'); + + expect(response.status).toBe(200); + expect(response.text).toContain('
'); + }); + + it('should not include live reload script by default', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: false }); + server = result; + app = result.app; + + const response = await request(app).get('/docs'); + + expect(response.status).toBe(200); + expect(response.text).not.toContain('__reload__'); + expect(response.text).not.toContain('location.reload()'); + }); + + it('should include live reload script when enabled', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: true }); + server = result; + app = result.app; + + const response = await request(app).get('/docs'); + + expect(response.status).toBe(200); + expect(response.text).toContain('__reload__'); + expect(response.text).toContain('location.reload()'); + }); + + it('should signal a reload when a docs page changes', async () => { + const liveDocsPath = await mkdtemp(join(tmpdir(), 'docs-live-')); + const pagePath = join(liveDocsPath, 'index.md'); + await writeFile(pagePath, '---\ntitle: Overview\ndescription: Live page\n---\n\nFirst version.\n'); + const result = await startServer({ docsPath: liveDocsPath, port: 0, liveReload: true }); + server = result; + app = result.app; + + const since = Date.now(); + let status = 0; + // the watcher can still be starting up, so keep editing until a change is picked up + for (let attempt = 0; attempt < 20 && status !== 205; attempt++) { + await writeFile(pagePath, `---\ntitle: Overview\ndescription: Live page\n---\n\nVersion ${attempt}.\n`); + await new Promise((resolve) => setTimeout(resolve, 100)); + status = (await request(app).get(`/__reload__?t=${since}`)).status; + } + + expect(status).toBe(205); + expect((await request(app).get('/docs')).text).toContain('Version'); + }); + + it('should have a live reload endpoint when enabled', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: true }); + server = result; + app = result.app; + + const response = await request(app).get('/__reload__?t=0'); + + expect([204, 205]).toContain(response.status); }); it('should escape HTML entities in titles to prevent XSS', async () => { @@ -207,26 +347,22 @@ describe('startServer', () => { server = result; app = result.app; - const response = await request(app).get('/'); + const response = await request(app).get('/docs'); expect(response.status).toBe(200); - // verify that if manifest or page titles contained HTML, it would be escaped - // manifest title should appear escaped in title tag and h1 expect(response.text).toMatch(/]*>[^<]*<\/title>/); - expect(response.text).toMatch(/]*>[^<]*<\/h1>/); - // navigation links should not contain unescaped HTML expect(response.text).not.toMatch(/]*>[^<]* diff --git a/packages/plugin-docs-cli/src/server/views/partials/navigation-item.ejs b/packages/plugin-docs-cli/src/server/views/partials/navigation-item.ejs index defa2a4645..dd78c16936 100644 --- a/packages/plugin-docs-cli/src/server/views/partials/navigation-item.ejs +++ b/packages/plugin-docs-cli/src/server/views/partials/navigation-item.ejs @@ -1,27 +1,39 @@ <% -const isActive = page.slug === currentPath; -const hasChildren = page.children && page.children.length > 0; -const hasFile = page.file && page.file.length > 0; -const href = '/' + page.slug; +const hasChildren = item.children && item.children.length > 0; +const isOpen = item.isActive || item.descendantActive; +const baseIndent = depth * 12; +// leaf items always reserve the chevron slot so they line up under sibling items that have one +const headingIndent = baseIndent + 26; -%> -
  • - <% if (hasChildren) { %> - - <% } %> - <% if (hasFile) { %> - <%= page.title %> - <% } else { %> - <%= page.title %> +
  • +
    + <% if (hasChildren) { %> + + <% } else { %> + + <% } %> + ><%= item.label %> +
    + + <% if (item.isActive && item.headings && item.headings.length > 0) { %> + <% } %> + <% if (hasChildren) { %> -
      - <% page.children.forEach(child => { %> - <%- include('navigation-item', { page: child, currentPath }) %> - <% }) %> -
    +
      + <% item.children.forEach((child) => { %> + <%- include('navigation-item', { item: child, depth: depth + 1 }) %> + <% }); %> +
    <% } %>
  • diff --git a/packages/plugin-docs-cli/src/server/views/partials/navigation.ejs b/packages/plugin-docs-cli/src/server/views/partials/navigation.ejs index e7acf07327..5dbef23127 100644 --- a/packages/plugin-docs-cli/src/server/views/partials/navigation.ejs +++ b/packages/plugin-docs-cli/src/server/views/partials/navigation.ejs @@ -1,5 +1,5 @@
      - <% pages.forEach(page => { %> - <%- include('navigation-item', { page, currentPath }) %> - <% }) %> + <% items.forEach((item) => { %> + <%- include('navigation-item', { item: item, depth: 0 }) %> + <% }); %>
    diff --git a/packages/plugin-docs-cli/src/server/views/partials/toc.ejs b/packages/plugin-docs-cli/src/server/views/partials/toc.ejs index 21ad4ffeff..347611fa29 100644 --- a/packages/plugin-docs-cli/src/server/views/partials/toc.ejs +++ b/packages/plugin-docs-cli/src/server/views/partials/toc.ejs @@ -1,12 +1,11 @@ +

    On this page

    <% if (!headings || headings.length === 0) { %> -

    No headings found

    +

    No headings found

    <% } else { %> diff --git a/packages/plugin-docs-cli/src/utils/utils.plugin.ts b/packages/plugin-docs-cli/src/utils/utils.plugin.ts index efb9a3ae00..f211c442b4 100644 --- a/packages/plugin-docs-cli/src/utils/utils.plugin.ts +++ b/packages/plugin-docs-cli/src/utils/utils.plugin.ts @@ -43,3 +43,28 @@ export async function resolvePluginJson(projectRoot?: string): Promise %s', pluginJson.docsPath, docsPath); return { docsPath, pluginType: pluginJson.type }; } + +/** + * README locations in priority order, matching create-plugin's copyFiles rule: `src/README.md` + * wins over the repo-root `README.md`. + */ +export function readmeCandidates(projectRoot?: string): string[] { + const root = projectRoot || process.cwd(); + return [join(root, 'src', 'README.md'), join(root, 'README.md')]; +} + +/** Reads the first README candidate that exists. Returns undefined when none does. */ +export async function readFirstReadme(candidates: string[]): Promise { + for (const candidate of candidates) { + try { + return await readFile(candidate, 'utf-8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'EISDIR') { + throw error; + } + } + } + debug('No README found in %O', candidates); + return undefined; +} diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts index 304469b559..dea77eb9d4 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts @@ -9,7 +9,10 @@ describe('checkFilesystem', () => { const testDocsPath = join(__dirname, '..', '..', '__fixtures__', 'test-docs'); it('should report missing root index.md', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); + const tmp = await mkdtemp(join(tmpdir(), 'docs-no-index-')); + await writeFile(join(tmp, 'guide.md'), '---\ntitle: Guide\ndescription: A guide\n---\n# Guide\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); const finding = findings.find((f) => f.rule === Rule.RootIndex); expect(finding).toBeDefined();