Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ sidebar_position: 2
# Database

Configure database connections.

![Database screenshot](img/db-config.png)
![Shared logo](../img/test.png)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions packages/plugin-docs-cli/src/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion packages/plugin-docs-cli/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,13 @@ export async function startServer(options: ServerOptions): Promise<Server> {
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', {
Expand Down
16 changes: 14 additions & 2 deletions packages/plugin-docs-parser/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Comment thread
sunker marked this conversation as resolved.
// 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) => {
Expand All @@ -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);
});
Comment thread
sunker marked this conversation as resolved.
};
}
Loading