diff --git a/changelog.d/fixed-362-rewrite-image-paths.md b/changelog.d/fixed-362-rewrite-image-paths.md new file mode 100644 index 00000000..145d3af4 --- /dev/null +++ b/changelog.d/fixed-362-rewrite-image-paths.md @@ -0,0 +1 @@ +- Fixed `rewriteRelativeImagePaths` so `../` image references resolve inside docs/content (instead of 403) and titled images (`![alt](img.png "Title")`) no longer 404. diff --git a/src/__tests__/rewriteImagePaths.test.ts b/src/__tests__/rewriteImagePaths.test.ts index 6add2b62..7927b290 100644 --- a/src/__tests__/rewriteImagePaths.test.ts +++ b/src/__tests__/rewriteImagePaths.test.ts @@ -73,4 +73,36 @@ describe("rewriteRelativeImagePaths", () => { const twice = rewriteRelativeImagePaths(once, "console"); expect(twice).toBe(once); }); + + it("resolves ../ against baseDir", () => { + expect( + rewriteRelativeImagePaths("![logo](../images/logo.png)", "hive/guides") + ).toBe("![logo](/docs-images/hive/images/logo.png)"); + }); + + it("leaves ../ that escapes docs/content unchanged", () => { + const md = "![logo](../../../etc/logo.png)"; + expect(rewriteRelativeImagePaths(md, "hive/guides")).toBe(md); + expect(rewriteRelativeImagePaths("![x](../x.png)", "")).toBe( + "![x](../x.png)" + ); + }); + + it("preserves a double-quoted title", () => { + expect( + rewriteRelativeImagePaths('![logo](images/logo.png "Logo")', "hive") + ).toBe('![logo](/docs-images/hive/images/logo.png "Logo")'); + }); + + it("preserves a single-quoted title", () => { + expect( + rewriteRelativeImagePaths("![logo](./images/logo.png 'Logo')", "hive") + ).toBe("![logo](/docs-images/hive/images/logo.png 'Logo')"); + }); + + it("still rewrites a plain relative path without a title", () => { + expect(rewriteRelativeImagePaths("![a](images/a.png)", "")).toBe( + "![a](/docs-images/images/a.png)" + ); + }); }); diff --git a/src/lib/rewriteImagePaths.ts b/src/lib/rewriteImagePaths.ts index a264793e..17d211d9 100644 --- a/src/lib/rewriteImagePaths.ts +++ b/src/lib/rewriteImagePaths.ts @@ -19,7 +19,11 @@ export function rewriteRelativeImagePaths( return content.replace( /!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt: string, src: string) => { - const trimmed = src.trim(); + const raw = src.trim(); + // Split an optional CommonMark title (` "..."` or ` '...'`) off the destination. + const titleMatch = /^(\S+)(\s+(?:"[^"]*"|'[^']*'))$/.exec(raw); + const trimmed = titleMatch ? titleMatch[1] : raw; + const title = titleMatch ? titleMatch[2] : ""; // Leave absolute URLs, absolute paths, data URIs, and anchor-only refs unchanged. if ( trimmed.startsWith("/") || @@ -29,11 +33,23 @@ export function rewriteRelativeImagePaths( ) { return match; } - const normalised = trimmed.replace(/^\.\//, ""); - const apiPath = baseDir - ? `/docs-images/${baseDir}/${normalised}` - : `/docs-images/${normalised}`; - return `![${alt}](${apiPath})`; + // Pure string normalisation (this module may run in the browser). + const segments: string[] = []; + const parts = [ + ...(baseDir ? baseDir.split("/") : []), + ...trimmed.split("/"), + ]; + for (const part of parts) { + if (part === "" || part === ".") continue; + if (part === "..") { + // Escapes docs/content: leave the original reference unchanged. + if (segments.length === 0) return match; + segments.pop(); + } else { + segments.push(part); + } + } + return `![${alt}](/docs-images/${segments.join("/")}${title})`; } ); }