diff --git a/packages/plugin-docs-cli/src/__fixtures__/test-docs/config/database.md b/packages/plugin-docs-cli/src/__fixtures__/test-docs/config/database.md index ccf14c8e5c..72ff425b50 100644 --- a/packages/plugin-docs-cli/src/__fixtures__/test-docs/config/database.md +++ b/packages/plugin-docs-cli/src/__fixtures__/test-docs/config/database.md @@ -7,3 +7,6 @@ sidebar_position: 2 # Database Configure database connections. + +![Database screenshot](img/db-config.png) +![Shared logo](../img/test.png) diff --git a/packages/plugin-docs-cli/src/__fixtures__/test-docs/config/img/db-config.png b/packages/plugin-docs-cli/src/__fixtures__/test-docs/config/img/db-config.png new file mode 100644 index 0000000000..613754cfaf Binary files /dev/null and b/packages/plugin-docs-cli/src/__fixtures__/test-docs/config/img/db-config.png differ diff --git a/packages/plugin-docs-cli/src/server/server.test.ts b/packages/plugin-docs-cli/src/server/server.test.ts index a10fd7c242..cece5f4bb2 100644 --- a/packages/plugin-docs-cli/src/server/server.test.ts +++ b/packages/plugin-docs-cli/src/server/server.test.ts @@ -91,6 +91,20 @@ describe('startServer', () => { expect(response.body).toBeDefined(); }); + it('should rewrite relative image srcs to root-relative URLs based on the page directory', async () => { + const result = await startServer({ docsPath: testDocsPath, port: 0 }); + server = result; + app = result.app; + + const response = await request(app).get('/config/database'); + + 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"'); + }); + it('should not include live reload script by default', async () => { const result = await startServer({ docsPath: testDocsPath, port: 0, liveReload: false }); server = result; diff --git a/packages/plugin-docs-cli/src/server/server.ts b/packages/plugin-docs-cli/src/server/server.ts index f5274b0aed..0f9bf0ab49 100644 --- a/packages/plugin-docs-cli/src/server/server.ts +++ b/packages/plugin-docs-cli/src/server/server.ts @@ -160,7 +160,13 @@ export async function startServer(options: ServerOptions): Promise { return; } - const parsed = parseMarkdown(fileContent); + // route through the parser's asset rewriting so local preview exercises the same + // code path as production. assetBaseUrl '/' produces root-relative srcs which the + // express.static handler at the docs root serves unchanged. + const parsed = parseMarkdown(fileContent, { + assetBaseUrl: '/', + file: page.file, + }); const title = page.title || slug; res.render('docs-layout', { diff --git a/packages/plugin-docs-parser/src/parser.ts b/packages/plugin-docs-parser/src/parser.ts index 32aa09f130..9a39d1ddfb 100644 --- a/packages/plugin-docs-parser/src/parser.ts +++ b/packages/plugin-docs-parser/src/parser.ts @@ -21,10 +21,19 @@ export type { Heading } from './types.js'; export interface ParseOptions { /** * Base URL for resolving relative image/asset paths. - * When set, relative image src attributes are rewritten to absolute CDN URLs. + * When set, relative image src attributes are rewritten. + * Must be an absolute URL or a root-relative path. * Example: "https://plugins-cdn.grafana-dev.net/my-plugin/1.0.0/public/plugins/my-plugin/docs" */ assetBaseUrl?: string; + + /** + * Path to the doc file relative to the docs root, forward-slash separated, no leading slash + * (e.g. "examples/azure.md"). Pass `Page.file` from the manifest. When set, relative image + * srcs resolve from the doc file's directory rather than from `assetBaseUrl`. Has no effect + * when `assetBaseUrl` is omitted. + */ + file?: string; } /** @@ -90,7 +99,10 @@ export function parseMarkdown(content: string, options?: ParseOptions): ParsedMa // rewrite asset paths before sanitization so URLs are final if (options?.assetBaseUrl) { - processor.use(rehypeRewriteAssetPaths, { assetBaseUrl: options.assetBaseUrl }); + processor.use(rehypeRewriteAssetPaths, { + assetBaseUrl: options.assetBaseUrl, + file: options.file, + }); } // rewrite relative .md links to clean URLs diff --git a/packages/plugin-docs-parser/src/plugins/rehype-rewrite-asset-paths.test.ts b/packages/plugin-docs-parser/src/plugins/rehype-rewrite-asset-paths.test.ts index 0b23319885..fa32f12032 100644 --- a/packages/plugin-docs-parser/src/plugins/rehype-rewrite-asset-paths.test.ts +++ b/packages/plugin-docs-parser/src/plugins/rehype-rewrite-asset-paths.test.ts @@ -94,4 +94,157 @@ describe('rehypeRewriteAssetPaths', () => { expect(node.properties.src).toBeUndefined(); }); + + describe('with file option (per-page resolution)', () => { + const baseWithFile = 'https://cdn.example.com/foo/docs'; + + it('should resolve relative src from the doc file directory, not the docs root', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: 'examples/azure.md' }); + const node = img('img/screenshot.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/examples/img/screenshot.png`); + }); + + it('should normalize ./ in relative srcs', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: 'examples/azure.md' }); + const node = img('./img/screenshot.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/examples/img/screenshot.png`); + }); + + it('should resolve ../ relative srcs against the parent directory', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: 'examples/azure.md' }); + const node = img('../shared/logo.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/shared/logo.png`); + }); + + it('should resolve from assetBaseUrl when file is at the root', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: 'index.md' }); + const node = img('img/foo.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/img/foo.png`); + }); + + it('should resolve from assetBaseUrl when file is empty', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: '' }); + const node = img('img/foo.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/img/foo.png`); + }); + + it('should leave absolute and special srcs untouched even with file set', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: 'examples/azure.md' }); + const inputs = [ + 'https://example.com/logo.png', + 'http://example.com/logo.png', + '//example.com/logo.png', + '/absolute.png', + 'data:image/png;base64,iVBORw0KGgo=', + 'blob:http://localhost:3000/abc', + ]; + + for (const src of inputs) { + const node = img(src); + t(tree(node)); + expect(node.properties.src).toBe(src); + } + }); + + it('should handle a deeply nested file path', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: 'a/b/c/page.md' }); + const node = img('img/foo.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/a/b/c/img/foo.png`); + }); + + it('should handle assetBaseUrl with a trailing slash', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile + '/', file: 'examples/azure.md' }); + const node = img('img/foo.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/examples/img/foo.png`); + }); + + it('should tolerate a leading-slash file (normalized away)', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: '/examples/azure.md' }); + const node = img('img/foo.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/examples/img/foo.png`); + }); + + it('should tolerate Windows-style backslash separators in file', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: 'examples\\azure.md' }); + const node = img('img/foo.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/examples/img/foo.png`); + }); + + it('should tolerate mixed slashes in file', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: 'a\\b/c/page.md' }); + const node = img('img/foo.png'); + t(tree(node)); + + expect(node.properties.src).toBe(`${baseWithFile}/a/b/c/img/foo.png`); + }); + }); + + describe('skip behavior for non-relative srcs', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: 'https://cdn.example.com/docs', file: 'examples/azure.md' }); + + it.each([ + 'mailto:hello@example.com', + 'ftp://files.example.com/x.png', + 'irc://example.com', + 'tel:+1234567890', + 'javascript:alert(1)', + ])('should leave %s untouched', (src) => { + const node = img(src); + t(tree(node)); + expect(node.properties.src).toBe(src); + }); + }); + + describe('protocol-relative assetBaseUrl', () => { + it('should throw when assetBaseUrl is protocol-relative', () => { + expect(() => rehypeRewriteAssetPaths({ assetBaseUrl: '//cdn.example.com/docs' })).toThrow(/protocol-relative/); + }); + }); + + describe('with root-relative assetBaseUrl (CLI use case)', () => { + it('should resolve relative srcs against the doc file directory and produce a root-relative URL', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: '/', file: 'examples/azure.md' }); + const node = img('img/foo.png'); + t(tree(node)); + + expect(node.properties.src).toBe('/examples/img/foo.png'); + }); + + it('should resolve from a root-relative subpath', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: '/static/docs', file: 'examples/azure.md' }); + const node = img('img/foo.png'); + t(tree(node)); + + expect(node.properties.src).toBe('/static/docs/examples/img/foo.png'); + }); + + it('should leave non-relative srcs untouched', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: '/', file: 'examples/azure.md' }); + const inputs = ['https://example.com/x.png', 'data:image/png;base64,iVBORw0KGgo=', '/already/absolute.png']; + + for (const src of inputs) { + const node = img(src); + t(tree(node)); + expect(node.properties.src).toBe(src); + } + }); + }); }); diff --git a/packages/plugin-docs-parser/src/plugins/rehype-rewrite-asset-paths.ts b/packages/plugin-docs-parser/src/plugins/rehype-rewrite-asset-paths.ts index 8eebd71da9..55e23a2ce4 100644 --- a/packages/plugin-docs-parser/src/plugins/rehype-rewrite-asset-paths.ts +++ b/packages/plugin-docs-parser/src/plugins/rehype-rewrite-asset-paths.ts @@ -2,15 +2,88 @@ import type { Root, Element } from 'hast'; import { visit } from 'unist-util-visit'; export interface RewriteAssetPathsOptions { - /** Base URL for resolving relative image/asset paths to absolute CDN URLs. */ + /** + * Base URL for resolving relative image/asset paths. + * Must be an absolute URL (e.g. `"https://cdn.example.com/foo/docs"`) or a root-relative path + * (e.g. `"/"` or `"/static/docs"`). Protocol-relative bases (`//host/...`) are not supported + * and will throw. + */ assetBaseUrl: string; + + /** + * Path to the doc file relative to the docs root (e.g. `"examples/azure.md"`). Pass + * `Page.file` from the manifest. When set, relative srcs are resolved from the doc file's + * directory rather than from the docs root. When omitted, relative srcs are resolved from + * `assetBaseUrl` (legacy behavior). + * + * Forward-slash separation is the documented format, but OS-specific separators (`\\`) and + * a leading `/` are tolerated and normalized away so callers can forward `node:path` output + * without a manual cleanup step. + */ + file?: string; +} + +// synthetic origin used to coerce root-relative bases into a fully-qualified URL the URL +// constructor can resolve against. stripped from the result before returning. +const SYNTHETIC_ORIGIN = 'https://_resolver_.local'; + +// matches any RFC 3986 scheme-prefixed URL (http:, https:, data:, blob:, mailto:, ftp:, ...). +// used to skip srcs that are already absolute regardless of scheme. +const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; + +function isAbsoluteHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith('/') ? value : value + '/'; +} + +function normalizeFile(file: string): string { + // tolerate node:path output on Windows (`examples\azure.md`) and a stray leading slash + return file.replace(/\\/g, '/').replace(/^\/+/, ''); +} + +function resolveSrc(src: string, assetBaseUrl: string, file: string | undefined): string { + const baseWithSlash = ensureTrailingSlash(assetBaseUrl); + const isAbs = isAbsoluteHttpUrl(assetBaseUrl); + const fullBase = isAbs ? new URL(baseWithSlash) : new URL(baseWithSlash, SYNTHETIC_ORIGIN); + + // resolve relative to the doc file's directory if `file` was provided + let dirBase = fullBase; + if (file) { + const normalized = normalizeFile(file); + const lastSlash = normalized.lastIndexOf('/'); + if (lastSlash >= 0) { + dirBase = new URL(normalized.slice(0, lastSlash + 1), fullBase); + } + } + + const resolved = new URL(src, dirBase); + + if (isAbs) { + return resolved.toString(); + } + // root-relative input: strip the synthetic origin + return resolved.pathname + resolved.search + resolved.hash; } /** - * Rehype plugin that rewrites relative image `src` attributes to absolute CDN URLs. + * Rehype plugin that rewrites relative image `src` attributes to absolute (or root-relative) + * URLs, using URL-style resolution semantics. + * + * - `./foo` and `../foo` are normalized + * - any `scheme:` URL (`http:`, `https:`, `data:`, `blob:`, `mailto:`, `ftp:`, ...), + * protocol-relative (`//`) and root-relative (`/`) srcs are left untouched + * - When `file` is provided, relative srcs resolve from the doc file's directory; otherwise + * they resolve from `assetBaseUrl` */ export function rehypeRewriteAssetPaths(options: RewriteAssetPathsOptions) { - const base = options.assetBaseUrl.replace(/\/$/, ''); + if (options.assetBaseUrl.startsWith('//')) { + throw new TypeError( + `assetBaseUrl must be an absolute URL or a root-relative path; protocol-relative URLs are not supported (got: ${options.assetBaseUrl})` + ); + } return (tree: Root) => { visit(tree, 'element', (node: Element) => { @@ -23,19 +96,12 @@ export function rehypeRewriteAssetPaths(options: RewriteAssetPathsOptions) { return; } - // skip absolute, protocol-relative, data, blob and root-relative URLs - if ( - src.startsWith('http://') || - src.startsWith('https://') || - src.startsWith('//') || - src.startsWith('data:') || - src.startsWith('blob:') || - src.startsWith('/') - ) { + // skip any already-absolute src: schemed (http:, data:, mailto:, ...), protocol-relative, root-relative + if (SCHEME_RE.test(src) || src.startsWith('//') || src.startsWith('/')) { return; } - node.properties.src = `${base}/${src}`; + node.properties.src = resolveSrc(src, options.assetBaseUrl, options.file); }); }; }