From ae9b0b8f8ff8473bf590fb6f3093b9fb2e4a4809 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 30 Sep 2026 14:43:54 +0200 Subject: [PATCH 01/12] feat(plugin-docs-cli): resolve plugin readme path --- .../plugin-docs-cli/src/utils/utils.plugin.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/plugin-docs-cli/src/utils/utils.plugin.ts b/packages/plugin-docs-cli/src/utils/utils.plugin.ts index ab198f6b27..e31b48cb42 100644 --- a/packages/plugin-docs-cli/src/utils/utils.plugin.ts +++ b/packages/plugin-docs-cli/src/utils/utils.plugin.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import { resolve, join } from 'node:path'; import createDebug from 'debug'; @@ -38,3 +38,23 @@ export async function resolveDocsPath(projectRoot?: string): Promise { debug('Resolved docsPath from plugin.json: %s -> %s', pluginJson.docsPath, docsPath); return docsPath; } + +/** + * Resolves the plugin's README, matching create-plugin's copyFiles rule: `src/README.md` when + * it exists, otherwise the repo-root `README.md`. Returns undefined when neither exists. + */ +export async function resolveReadmePath(projectRoot?: string): Promise { + const root = projectRoot || process.cwd(); + const candidates = [join(root, 'src', 'README.md'), join(root, 'README.md')]; + for (const candidate of candidates) { + try { + const st = await stat(candidate); + if (st.isFile()) { + debug('Resolved README path: %s', candidate); + return candidate; + } + } catch {} + } + debug('No README found under %s', root); + return undefined; +} From 87ed0a5618f7c4317effa65722dce2ceb2b80429 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 30 Sep 2026 14:44:02 +0200 Subject: [PATCH 02/12] feat(plugin-docs-cli): port catalog docs nav helpers --- packages/plugin-docs-cli/src/server/nav.ts | 215 +++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 packages/plugin-docs-cli/src/server/nav.ts 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..92eef5a8bf --- /dev/null +++ b/packages/plugin-docs-cli/src/server/nav.ts @@ -0,0 +1,215 @@ +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 function docsBasePath(): string { + return '/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 = docsBasePath()): 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 = docsBasePath()): 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 dir = currentFile.includes('/') ? currentFile.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; +} From 45fe3977fb0bd85619fd213512a25cae08868c55 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 30 Sep 2026 14:44:04 +0200 Subject: [PATCH 03/12] feat(plugin-docs-cli): align preview with catalog-website layout --- package-lock.json | 2 +- packages/plugin-docs-cli/package.json | 1 + .../src/__fixtures__/test-docs/index.md | 7 + .../src/__fixtures__/test-readme/README.md | 15 + .../__fixtures__/unsafe-slug-docs/index.md | 6 + packages/plugin-docs-cli/src/bin/run.ts | 5 +- .../src/commands/build.command.test.ts | 9 +- .../src/commands/serve.command.ts | 3 +- packages/plugin-docs-cli/src/scanner.test.ts | 45 +- .../plugin-docs-cli/src/server/server.test.ts | 230 ++++-- packages/plugin-docs-cli/src/server/server.ts | 295 ++++++-- .../src/server/styles/docs.css | 710 ++++++++++-------- .../src/server/views/docs-layout.ejs | 115 +-- .../server/views/partials/navigation-item.ejs | 54 +- .../src/server/views/partials/navigation.ejs | 6 +- .../src/server/views/partials/toc.ejs | 11 +- .../src/validation/rules/filesystem.test.ts | 5 +- 17 files changed, 993 insertions(+), 526 deletions(-) create mode 100644 packages/plugin-docs-cli/src/__fixtures__/test-docs/index.md create mode 100644 packages/plugin-docs-cli/src/__fixtures__/test-readme/README.md create mode 100644 packages/plugin-docs-cli/src/__fixtures__/unsafe-slug-docs/index.md diff --git a/package-lock.json b/package-lock.json index 47cdefe38d..c72dc52aaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22603,7 +22603,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" @@ -35159,6 +35158,7 @@ "github-slugger": "^1.5.0", "gray-matter": "^4.0.3", "hast-util-to-html": "^9.0.0", + "marked": "^16.3.0", "minimist": "^1.2.8" }, "bin": { diff --git a/packages/plugin-docs-cli/package.json b/packages/plugin-docs-cli/package.json index dfb27a9678..3f82d2aa7a 100644 --- a/packages/plugin-docs-cli/package.json +++ b/packages/plugin-docs-cli/package.json @@ -54,6 +54,7 @@ "github-slugger": "^1.5.0", "gray-matter": "^4.0.3", "hast-util-to-html": "^9.0.0", + "marked": "^16.3.0", "minimist": "^1.2.8" }, "devDependencies": { 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 d3612a720b..d6b8df67d0 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 { resolveDocsPath } from '../utils/utils.plugin.js'; +import { resolveDocsPath, resolveReadmePath } 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'; @@ -68,7 +68,8 @@ async function main() { reload: false, }, }); - await serve(serveArgv, docsPath); + const readmePath = await resolveReadmePath(); + await serve(serveArgv, docsPath, readmePath); 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 1faea2aed9..d4c304082d 100644 --- a/packages/plugin-docs-cli/src/commands/serve.command.ts +++ b/packages/plugin-docs-cli/src/commands/serve.command.ts @@ -4,7 +4,7 @@ import { startServer } from '../server/server.js'; const debug = createDebug('plugin-docs-cli:serve'); -export const serve = async (argv: minimist.ParsedArgs, docsPath: string) => { +export const serve = async (argv: minimist.ParsedArgs, docsPath: string, readmePath?: string) => { debug('Serve command invoked with args: %O', argv); // parse port @@ -19,6 +19,7 @@ export const serve = async (argv: minimist.ParsedArgs, docsPath: string) => { try { await startServer({ docsPath, + readmePath, port, liveReload, }); 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/server.test.ts b/packages/plugin-docs-cli/src/server/server.test.ts index fbf40f7df9..b4efec2d7b 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, 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,260 @@ 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, readmePath: 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 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, readmePath: 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, readmePath: 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 { + 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 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 +283,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/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(); From ce4cfd81f5236af92b77309f67aac070b459cfd2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 30 Sep 2026 16:42:51 +0200 Subject: [PATCH 04/12] fix(plugin-docs-cli): sanitize preview readme like gcom --- package-lock.json | 6 ++---- packages/plugin-docs-cli/package.json | 3 ++- .../plugin-docs-cli/src/server/server.test.ts | 21 +++++++++++++++++++ packages/plugin-docs-cli/src/server/server.ts | 20 ++++++++++++++++-- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index c72dc52aaa..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": { @@ -34729,7 +34728,6 @@ }, "node_modules/xss": { "version": "1.0.15", - "dev": true, "license": "MIT", "dependencies": { "commander": "^2.20.3", @@ -34744,7 +34742,6 @@ }, "node_modules/xss/node_modules/commander": { "version": "2.20.3", - "dev": true, "license": "MIT" }, "node_modules/xtend": { @@ -35159,7 +35156,8 @@ "gray-matter": "^4.0.3", "hast-util-to-html": "^9.0.0", "marked": "^16.3.0", - "minimist": "^1.2.8" + "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 3f82d2aa7a..6b25417453 100644 --- a/packages/plugin-docs-cli/package.json +++ b/packages/plugin-docs-cli/package.json @@ -55,7 +55,8 @@ "gray-matter": "^4.0.3", "hast-util-to-html": "^9.0.0", "marked": "^16.3.0", - "minimist": "^1.2.8" + "minimist": "^1.2.8", + "xss": "^1.0.15" }, "devDependencies": { "@types/debug": "^4.1.12", diff --git a/packages/plugin-docs-cli/src/server/server.test.ts b/packages/plugin-docs-cli/src/server/server.test.ts index b4efec2d7b..0dd686e8f9 100644 --- a/packages/plugin-docs-cli/src/server/server.test.ts +++ b/packages/plugin-docs-cli/src/server/server.test.ts @@ -226,6 +226,27 @@ describe('startServer', () => { expect(response.status).toBe(200); 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\nx\n\n- [x] done\n' + ); + const result = await startServer({ docsPath: testDocsPath, readmePath: 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 () => { diff --git a/packages/plugin-docs-cli/src/server/server.ts b/packages/plugin-docs-cli/src/server/server.ts index 7568cf35c8..a3caa31943 100644 --- a/packages/plugin-docs-cli/src/server/server.ts +++ b/packages/plugin-docs-cli/src/server/server.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { watch } from 'chokidar'; import createDebug from 'debug'; import { marked } from 'marked'; +import xss, { whiteList } from 'xss'; import Slugger from 'github-slugger'; import { parseMarkdown, type Manifest, type Page } from '@grafana/plugin-docs-parser'; import { toHtml } from 'hast-util-to-html'; @@ -331,14 +332,29 @@ function renderReadme(source: string): ReadmeResult { }; const raw = marked.parse(source, { renderer, async: false, gfm: true, breaks: false }) as string; - // wrap top-level tables so a wide table can scroll horizontally without pushing the column - const html = raw.replace( + // wrap top-level tables so a wide table can scroll horizontally without pushing the column. + // runs after sanitizing, which would otherwise strip the wrapper's class and tabindex. + const html = sanitizeReadme(raw).replace( /]*)?>([\s\S]*?)<\/table>/g, '
    $2
    ' ); return { html, headings }; } +// mirrors gcom's readme sanitizing (plugin-version.model.ts markdown2Html) so the preview strips what +// grafana.com strips. h2/h3 also keep their id, which the "on this page" rail links to. +function sanitizeReadme(html: string): string { + return xss(html, { + whiteList: { ...whiteList, code: ['class'], h2: ['id'], h3: ['id'] }, + onIgnoreTag: (tag, tagHtml) => { + if (tag === 'input' && tagHtml.includes('disabled=""') && tagHtml.includes('type="checkbox"')) { + return tagHtml; + } + return undefined; + }, + }); +} + function escapeAttr(input: string): string { return input.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } From 7d1303d11bb00766783ee02436a95da62c38a4d2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 30 Sep 2026 16:44:02 +0200 Subject: [PATCH 05/12] fix(plugin-docs-cli): re-read preview readme on each request --- packages/plugin-docs-cli/src/bin/run.ts | 5 ++- .../src/commands/serve.command.ts | 9 ++++-- .../plugin-docs-cli/src/server/server.test.ts | 32 ++++++++++++++++--- packages/plugin-docs-cli/src/server/server.ts | 18 +++++------ .../plugin-docs-cli/src/utils/utils.plugin.ts | 27 +++++++++------- 5 files changed, 60 insertions(+), 31 deletions(-) diff --git a/packages/plugin-docs-cli/src/bin/run.ts b/packages/plugin-docs-cli/src/bin/run.ts index eda04e24b9..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, resolveReadmePath } 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,8 +69,7 @@ async function main() { reload: false, }, }); - const readmePath = await resolveReadmePath(); - await serve(serveArgv, docsPath, pluginType, readmePath); + await serve(serveArgv, docsPath, pluginType, readmeCandidates()); break; } case 'build': { diff --git a/packages/plugin-docs-cli/src/commands/serve.command.ts b/packages/plugin-docs-cli/src/commands/serve.command.ts index dbc4f14105..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, readmePath?: 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,7 +24,7 @@ export const serve = async (argv: minimist.ParsedArgs, docsPath: string, pluginT try { await startServer({ docsPath, - readmePath, + readmePaths, port, liveReload, pluginType, diff --git a/packages/plugin-docs-cli/src/server/server.test.ts b/packages/plugin-docs-cli/src/server/server.test.ts index 0dd686e8f9..6c62b2cbb6 100644 --- a/packages/plugin-docs-cli/src/server/server.test.ts +++ b/packages/plugin-docs-cli/src/server/server.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import request from 'supertest'; import { join } from 'node:path'; -import { mkdtemp, writeFile } from 'node:fs/promises'; +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'; @@ -23,7 +23,7 @@ describe('startServer', () => { }); it('should render the README on the Overview tab (/)', async () => { - const result = await startServer({ docsPath: testDocsPath, readmePath: testReadmePath, port: 0 }); + const result = await startServer({ docsPath: testDocsPath, readmePaths: [testReadmePath], port: 0 }); server = result; app = result.app; @@ -35,6 +35,28 @@ describe('startServer', () => { expect(response.text).toContain('This is the plugin readme'); }); + 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; @@ -106,7 +128,7 @@ describe('startServer', () => { 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, readmePath: testReadmePath, port: 0 }); + const result = await startServer({ docsPath: noIndexDocsPath, readmePaths: [testReadmePath], port: 0 }); server = result; app = result.app; @@ -218,7 +240,7 @@ describe('startServer', () => { }); it('should use plain heading text for README "On this page" labels', async () => { - const result = await startServer({ docsPath: testDocsPath, readmePath: testReadmePath, port: 0 }); + const result = await startServer({ docsPath: testDocsPath, readmePaths: [testReadmePath], port: 0 }); server = result; app = result.app; @@ -236,7 +258,7 @@ describe('startServer', () => { unsafeReadmePath, '# Plugin\n\n\n\nx\n\n- [x] done\n' ); - const result = await startServer({ docsPath: testDocsPath, readmePath: unsafeReadmePath, port: 0 }); + const result = await startServer({ docsPath: testDocsPath, readmePaths: [unsafeReadmePath], port: 0 }); server = result; app = result.app; diff --git a/packages/plugin-docs-cli/src/server/server.ts b/packages/plugin-docs-cli/src/server/server.ts index a3caa31943..b335d5a305 100644 --- a/packages/plugin-docs-cli/src/server/server.ts +++ b/packages/plugin-docs-cli/src/server/server.ts @@ -1,5 +1,4 @@ import express, { type Express, type Request, type Response } from 'express'; -import { readFile } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { watch } from 'chokidar'; @@ -13,6 +12,7 @@ import { scanDocsFolder } from '../scanner.js'; import { validate } from '../validation/engine.js'; import { formatResult } from '../validation/format.js'; import { allRules } from '../validation/rules/index.js'; +import { readFirstReadme } from '../utils/utils.plugin.js'; import { docPageHref, docsBasePath, @@ -35,7 +35,8 @@ const debug = createDebug('plugin-docs-cli:server'); export interface ServerOptions { docsPath: string; - readmePath?: string; + /** README locations in priority order. Re-read on every request, so adding or removing one needs no restart. */ + readmePaths?: string[]; port: number; liveReload?: boolean; pluginType?: string; @@ -70,7 +71,7 @@ interface RenderNavItem extends NavItem { * the active page's h2/h3 nested underneath. */ export async function startServer(options: ServerOptions): Promise { - const { docsPath, readmePath, port = 3001, liveReload = false, pluginType } = options; + const { docsPath, readmePaths = [], port = 3001, liveReload = false, pluginType } = options; debug('Starting server with options: docsPath=%s, port=%d, liveReload=%s', docsPath, port, liveReload); @@ -119,11 +120,8 @@ export async function startServer(options: ServerOptions): Promise { // validate on startup await runValidation(); - // watch markdown files under docsPath and, if present, the README, so both trigger reloads - const watchPaths = [join(docsPath, '**/*.md')]; - if (readmePath) { - watchPaths.push(readmePath); - } + // watch the README candidates too, even ones that don't exist yet, so adding one triggers a reload + const watchPaths = [join(docsPath, '**/*.md'), ...readmePaths]; const watcher = watch(watchPaths, { ignoreInitial: true, }); @@ -173,7 +171,8 @@ export async function startServer(options: ServerOptions): Promise { // Overview tab: render the plugin README with marked, matching how gcom stores it. app.get('/', async (_req: Request, res: Response) => { try { - if (!readmePath) { + const raw = await readFirstReadme(readmePaths); + if (raw === undefined) { res.status(200).render( 'docs-layout', baseLayoutContext('Overview', 'overview', manifest, liveReload, { @@ -185,7 +184,6 @@ export async function startServer(options: ServerOptions): Promise { return; } - const raw = await readFile(readmePath, 'utf-8'); const { html, headings } = renderReadme(raw); res.render( 'docs-layout', diff --git a/packages/plugin-docs-cli/src/utils/utils.plugin.ts b/packages/plugin-docs-cli/src/utils/utils.plugin.ts index e2aa07f1ec..f211c442b4 100644 --- a/packages/plugin-docs-cli/src/utils/utils.plugin.ts +++ b/packages/plugin-docs-cli/src/utils/utils.plugin.ts @@ -1,4 +1,4 @@ -import { readFile, stat } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { resolve, join } from 'node:path'; import createDebug from 'debug'; @@ -45,21 +45,26 @@ export async function resolvePluginJson(projectRoot?: string): Promise { +export function readmeCandidates(projectRoot?: string): string[] { const root = projectRoot || process.cwd(); - const candidates = [join(root, 'src', 'README.md'), join(root, 'README.md')]; + 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 { - const st = await stat(candidate); - if (st.isFile()) { - debug('Resolved README path: %s', candidate); - return candidate; + return await readFile(candidate, 'utf-8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'EISDIR') { + throw error; } - } catch {} + } } - debug('No README found under %s', root); + debug('No README found in %O', candidates); return undefined; } From 8f5d1b30444dd779575b24825dba2008eeac1983 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 30 Sep 2026 20:24:53 +0200 Subject: [PATCH 06/12] Enhance input tag handling for disabled checkboxes Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/plugin-docs-cli/src/server/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-docs-cli/src/server/server.ts b/packages/plugin-docs-cli/src/server/server.ts index b335d5a305..92ff4d2978 100644 --- a/packages/plugin-docs-cli/src/server/server.ts +++ b/packages/plugin-docs-cli/src/server/server.ts @@ -345,7 +345,7 @@ function sanitizeReadme(html: string): string { return xss(html, { whiteList: { ...whiteList, code: ['class'], h2: ['id'], h3: ['id'] }, onIgnoreTag: (tag, tagHtml) => { - if (tag === 'input' && tagHtml.includes('disabled=""') && tagHtml.includes('type="checkbox"')) { + if (tag === 'input' && /^$/i.test(tagHtml)) { return tagHtml; } return undefined; From 01bf23515d5d36ac79a463c34dc748aedd558805 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 30 Sep 2026 20:25:12 +0200 Subject: [PATCH 07/12] Normalize file path separators in resolveRelativePath Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/plugin-docs-cli/src/server/nav.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-docs-cli/src/server/nav.ts b/packages/plugin-docs-cli/src/server/nav.ts index 92eef5a8bf..34f9259611 100644 --- a/packages/plugin-docs-cli/src/server/nav.ts +++ b/packages/plugin-docs-cli/src/server/nav.ts @@ -186,8 +186,8 @@ export function resolveDocHref(href: string, currentFile: string, pages: Page[], return `${base}${suffix}`; } -function resolveRelativePath(path: string, currentFile: string): string | null { - const dir = currentFile.includes('/') ? currentFile.replace(/\/[^/]*$/, '/') : ''; + 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(/\/$/, ''); From 5d0a367431343f949a0dfb4bb93c477950c5fd9e Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 30 Sep 2026 20:31:50 +0200 Subject: [PATCH 08/12] fix(plugin-docs-cli): restore resolveRelativePath signature --- packages/plugin-docs-cli/src/server/nav.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-docs-cli/src/server/nav.ts b/packages/plugin-docs-cli/src/server/nav.ts index 34f9259611..42595b6e88 100644 --- a/packages/plugin-docs-cli/src/server/nav.ts +++ b/packages/plugin-docs-cli/src/server/nav.ts @@ -186,6 +186,7 @@ export function resolveDocHref(href: string, currentFile: string, pages: Page[], return `${base}${suffix}`; } +function resolveRelativePath(path: string, currentFile: string): string | null { const normalizedFile = currentFile.replace(/\\/g, '/'); const dir = normalizedFile.includes('/') ? normalizedFile.replace(/\/[^/]*$/, '/') : ''; try { From 645df629cfdcfdcdc2b62b773aa595c67a15ac79 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 1 Oct 2026 09:52:47 +0200 Subject: [PATCH 09/12] fix(plugin-docs-cli): watch docs folder so live reload fires --- .../plugin-docs-cli/src/server/server.test.ts | 21 +++++++++++++++++++ packages/plugin-docs-cli/src/server/server.ts | 6 ++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/plugin-docs-cli/src/server/server.test.ts b/packages/plugin-docs-cli/src/server/server.test.ts index 6c62b2cbb6..e967d7f058 100644 --- a/packages/plugin-docs-cli/src/server/server.test.ts +++ b/packages/plugin-docs-cli/src/server/server.test.ts @@ -311,6 +311,27 @@ describe('startServer', () => { 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; diff --git a/packages/plugin-docs-cli/src/server/server.ts b/packages/plugin-docs-cli/src/server/server.ts index 92ff4d2978..a53239bc5a 100644 --- a/packages/plugin-docs-cli/src/server/server.ts +++ b/packages/plugin-docs-cli/src/server/server.ts @@ -120,10 +120,12 @@ export async function startServer(options: ServerOptions): Promise { // validate on startup await runValidation(); - // watch the README candidates too, even ones that don't exist yet, so adding one triggers a reload - const watchPaths = [join(docsPath, '**/*.md'), ...readmePaths]; + // chokidar has no glob support, so watch the docs folder and skip non-markdown files. the README + // candidates are watched too, even ones that don't exist yet, so adding one triggers a reload. + const watchPaths = [docsPath, ...readmePaths]; const watcher = watch(watchPaths, { ignoreInitial: true, + ignored: (path, stats) => Boolean(stats?.isFile() && !path.endsWith('.md')), }); debug('File watcher initialized for %O', watchPaths); From d283bfee7b995cdd8e5f3a57e07c01d957389db2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 1 Oct 2026 09:55:03 +0200 Subject: [PATCH 10/12] fix(plugin-docs-cli): read xss whitelist from default export --- packages/plugin-docs-cli/src/server/server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/plugin-docs-cli/src/server/server.ts b/packages/plugin-docs-cli/src/server/server.ts index a53239bc5a..56c15411f0 100644 --- a/packages/plugin-docs-cli/src/server/server.ts +++ b/packages/plugin-docs-cli/src/server/server.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { watch } from 'chokidar'; import createDebug from 'debug'; import { marked } from 'marked'; -import xss, { whiteList } from 'xss'; +import xss, { type IWhiteList } from 'xss'; import Slugger from 'github-slugger'; import { parseMarkdown, type Manifest, type Page } from '@grafana/plugin-docs-parser'; import { toHtml } from 'hast-util-to-html'; @@ -343,6 +343,9 @@ function renderReadme(source: string): ReadmeResult { // mirrors gcom's readme sanitizing (plugin-version.model.ts markdown2Html) so the preview strips what // grafana.com strips. h2/h3 also keep their id, which the "on this page" rail links to. +// xss is CommonJS and Node's ESM loader can't see its named exports, so read whiteList off the default +const { whiteList } = xss as typeof xss & { whiteList: IWhiteList }; + function sanitizeReadme(html: string): string { return xss(html, { whiteList: { ...whiteList, code: ['class'], h2: ['id'], h3: ['id'] }, From 403c4870be8146973282b566187a45ee3eac4848 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 1 Oct 2026 11:39:22 +0200 Subject: [PATCH 11/12] refactor(plugin-docs-cli): use a DOCS_BASE constant for the docs root --- packages/plugin-docs-cli/src/server/nav.ts | 8 +++----- packages/plugin-docs-cli/src/server/server.ts | 17 ++++++++--------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/plugin-docs-cli/src/server/nav.ts b/packages/plugin-docs-cli/src/server/nav.ts index 42595b6e88..932a8b0224 100644 --- a/packages/plugin-docs-cli/src/server/nav.ts +++ b/packages/plugin-docs-cli/src/server/nav.ts @@ -11,12 +11,10 @@ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; const RESOLVER_ORIGIN = 'https://resolver.invalid/'; /** The docs root path (base for all doc urls). */ -export function docsBasePath(): string { - return '/docs'; -} +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 = docsBasePath()): string { +export function docPageHref(pageSlug: string, docsBase: string = DOCS_BASE): string { if (pageSlug === DOCS_INDEX_SLUG) { return docsBase; } @@ -117,7 +115,7 @@ export interface DocsNav { * 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 = docsBasePath()): DocsNav { +export function toDocsNav(pages: Page[], docsBase: string = DOCS_BASE): DocsNav { const headingsByHref: Record = {}; const toItems = (nodes: Page[]): NavItem[] => diff --git a/packages/plugin-docs-cli/src/server/server.ts b/packages/plugin-docs-cli/src/server/server.ts index 56c15411f0..23c49b5167 100644 --- a/packages/plugin-docs-cli/src/server/server.ts +++ b/packages/plugin-docs-cli/src/server/server.ts @@ -15,7 +15,7 @@ import { allRules } from '../validation/rules/index.js'; import { readFirstReadme } from '../utils/utils.plugin.js'; import { docPageHref, - docsBasePath, + DOCS_BASE, docsLandingPage, findDocAncestors, findDocPage, @@ -150,7 +150,7 @@ export async function startServer(options: ServerOptions): Promise { // serve docs assets (images) under /docs so they line up with the /docs/... page urls. // skip .md files so they're handled by the page route, not served raw. const docsStatic = express.static(docsPath, { index: false, redirect: false, dotfiles: 'ignore', extensions: [] }); - app.use('/docs', (req, res, next) => { + app.use(DOCS_BASE, (req, res, next) => { if (req.path.endsWith('.md')) { return next(); } @@ -201,7 +201,7 @@ export async function startServer(options: ServerOptions): Promise { }); // Documentation tab: /docs is the landing page (index.md); /docs/ is any other page. - app.get(['/docs', '/docs/{*splat}'], async (req: Request, res: Response) => { + app.get([DOCS_BASE, `${DOCS_BASE}/{*splat}`], async (req: Request, res: Response) => { try { const landing = docsLandingPage(manifest.pages); if (!landing) { @@ -234,18 +234,17 @@ export async function startServer(options: ServerOptions): Promise { // route through the parser's asset rewriting so local preview exercises the same // code path as production. assetBaseUrl '/docs/' produces srcs that the docs // express.static handler mounted at /docs serves unchanged. - const docsBase = docsBasePath(); const parsed = parseMarkdown(page.content, { - assetBaseUrl: `${docsBase}/`, + assetBaseUrl: `${DOCS_BASE}/`, file: page.file, }); - rewriteHast(parsed.hast, page.file, manifest.pages, docsBase); + rewriteHast(parsed.hast, page.file, manifest.pages, DOCS_BASE); - const nav = toDocsNav(manifest.pages, docsBase); - const activeHref = docPageHref(page.slug, docsBase); + const nav = toDocsNav(manifest.pages, DOCS_BASE); + const activeHref = docPageHref(page.slug, DOCS_BASE); const renderNav = decorateNav(nav.items, activeHref, nav.headingsByHref); - const breadcrumb = buildBreadcrumb(manifest.pages, page.slug, docsBase); + const breadcrumb = buildBreadcrumb(manifest.pages, page.slug, DOCS_BASE); res.render('docs-layout', { ...baseLayoutContext(page.title || page.slug, 'documentation', manifest, liveReload, { From 576b25494a4218d5149761bd83bbc136afcb09b2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 1 Oct 2026 11:40:11 +0200 Subject: [PATCH 12/12] test(plugin-docs-cli): add unit tests for the docs nav helpers --- .../plugin-docs-cli/src/server/nav.test.ts | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 packages/plugin-docs-cli/src/server/nav.test.ts 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'); + }); +});