From 2e94e7cd4f4e11375d89fdb11c5983e2937022ca Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 4 May 2026 15:23:15 +0200 Subject: [PATCH 1/3] resolve relative asset paths from doc file directory --- .../__fixtures__/test-docs/config/database.md | 3 + .../plugin-docs-cli/src/server/server.test.ts | 14 +++ packages/plugin-docs-cli/src/server/server.ts | 8 +- packages/plugin-docs-parser/src/parser.ts | 16 ++- .../rehype-rewrite-asset-paths.test.ts | 115 ++++++++++++++++++ .../src/plugins/rehype-rewrite-asset-paths.ts | 62 +++++++++- 6 files changed, 210 insertions(+), 8 deletions(-) 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/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..7b886dd871 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,119 @@ 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 not crash on a leading-slash file (invalid input, best-effort)', () => { + const t = rehypeRewriteAssetPaths({ assetBaseUrl: baseWithFile, file: '/examples/azure.md' }); + const node = img('img/foo.png'); + + expect(() => t(tree(node))).not.toThrow(); + expect(typeof node.properties.src).toBe('string'); + }); + }); + + 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..4d91065fa6 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,16 +2,68 @@ 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"`). Anything else will throw from the URL constructor. + */ 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 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). + */ + 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'; + +function isAbsoluteHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith('/') ? value : value + '/'; +} + +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 lastSlash = file.lastIndexOf('/'); + if (lastSlash >= 0) { + dirBase = new URL(file.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 + * - `data:`, `blob:`, protocol (`http://`, `https://`), 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(/\/$/, ''); - return (tree: Root) => { visit(tree, 'element', (node: Element) => { if (node.tagName !== 'img') { @@ -35,7 +87,7 @@ export function rehypeRewriteAssetPaths(options: RewriteAssetPathsOptions) { return; } - node.properties.src = `${base}/${src}`; + node.properties.src = resolveSrc(src, options.assetBaseUrl, options.file); }); }; } From cf27719bf6017133408dae6052f17b3e1c69cf8d Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 4 May 2026 15:40:52 +0200 Subject: [PATCH 2/3] add db-config.png fixture for new image-rewrite test --- .../test-docs/config/img/db-config.png | Bin 0 -> 70 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/plugin-docs-cli/src/__fixtures__/test-docs/config/img/db-config.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 0000000000000000000000000000000000000000..613754cfaf74a7a2d86984231479d5671731f18a GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8HA{D|jgU}|SSG{)78&qol`;+0KfSU_W%F@ literal 0 HcmV?d00001 From 64dd45e36fecdfa1b392fad8b42ac99c245a0fa9 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 4 May 2026 15:59:08 +0200 Subject: [PATCH 3/3] address copilot review: windows paths, scheme skip, protocol-relative guard --- .../rehype-rewrite-asset-paths.test.ts | 44 ++++++++++++++-- .../src/plugins/rehype-rewrite-asset-paths.ts | 50 ++++++++++++------- 2 files changed, 73 insertions(+), 21 deletions(-) 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 7b886dd871..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 @@ -172,12 +172,50 @@ describe('rehypeRewriteAssetPaths', () => { expect(node.properties.src).toBe(`${baseWithFile}/examples/img/foo.png`); }); - it('should not crash on a leading-slash file (invalid input, best-effort)', () => { + 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); + }); + }); - expect(() => t(tree(node))).not.toThrow(); - expect(typeof node.properties.src).toBe('string'); + describe('protocol-relative assetBaseUrl', () => { + it('should throw when assetBaseUrl is protocol-relative', () => { + expect(() => rehypeRewriteAssetPaths({ assetBaseUrl: '//cdn.example.com/docs' })).toThrow(/protocol-relative/); }); }); 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 4d91065fa6..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 @@ -5,15 +5,20 @@ export interface RewriteAssetPathsOptions { /** * 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"`). Anything else will throw from the URL constructor. + * (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, forward-slash separated, no leading slash - * (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). + * 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; } @@ -22,6 +27,10 @@ export interface RewriteAssetPathsOptions { // 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); } @@ -30,6 +39,11 @@ 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); @@ -38,9 +52,10 @@ function resolveSrc(src: string, assetBaseUrl: string, file: string | undefined) // resolve relative to the doc file's directory if `file` was provided let dirBase = fullBase; if (file) { - const lastSlash = file.lastIndexOf('/'); + const normalized = normalizeFile(file); + const lastSlash = normalized.lastIndexOf('/'); if (lastSlash >= 0) { - dirBase = new URL(file.slice(0, lastSlash + 1), fullBase); + dirBase = new URL(normalized.slice(0, lastSlash + 1), fullBase); } } @@ -58,12 +73,18 @@ function resolveSrc(src: string, assetBaseUrl: string, file: string | undefined) * URLs, using URL-style resolution semantics. * * - `./foo` and `../foo` are normalized - * - `data:`, `blob:`, protocol (`http://`, `https://`), protocol-relative (`//`) and - * root-relative (`/`) srcs are left untouched + * - 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) { + 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) => { if (node.tagName !== 'img') { @@ -75,15 +96,8 @@ 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; }