diff --git a/src/components/Markdown.svelte b/src/components/Markdown.svelte index e92f54a32..486280e89 100644 --- a/src/components/Markdown.svelte +++ b/src/components/Markdown.svelte @@ -37,6 +37,7 @@ twemoji, scrollIntoView, canonicalize, + canonicalizeGithubImageUrl, isCommit, } from "@app/lib/utils"; import { Renderer, markdown, sanitizeConfig } from "@app/lib/markdown"; @@ -226,10 +227,14 @@ const imagePath = i.getAttribute("src"); const imageClass = i.getAttribute("class"); - // Make sure the source isn't a URL before trying to fetch it from the repo const emoji = imageClass && imageClass === "txt-emoji"; - if (imagePath && !isUrl(imagePath) && !emoji) { - i.setAttribute("src", `${rawPath}/${canonicalize(imagePath, path)}`); + if (imagePath && !emoji) { + if (isUrl(imagePath)) { + // Rewrite GitHub "blob" URLs so the raw image content is rendered. + i.setAttribute("src", canonicalizeGithubImageUrl(imagePath)); + } else { + i.setAttribute("src", `${rawPath}/${canonicalize(imagePath, path)}`); + } } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b2a20b0cb..90dbd1c00 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -184,6 +184,29 @@ export function isUrl(input: string): boolean { return /^https?:\/\//.test(input); } +// Rewrite GitHub "blob" image URLs so they resolve to the raw image content. +// A URL like https://github.com///blob// serves an +// HTML page rather than the image bytes, so appending `?raw=true` makes GitHub +// redirect to the raw content. URLs already pointing at the raw content (either +// via `?raw=true` or raw.githubusercontent.com) are returned unchanged. +export function canonicalizeGithubImageUrl(input: string): string { + let url; + try { + url = new URL(input); + } catch { + return input; + } + if ( + (url.hostname === "github.com" || url.hostname === "www.github.com") && + /^\/[^/]+\/[^/]+\/blob\//.test(url.pathname) && + url.searchParams.get("raw") !== "true" + ) { + url.searchParams.set("raw", "true"); + return url.toString(); + } + return input; +} + export function isCommit(input: string): boolean { return /^[a-f0-9]{40}$/.test(input); } diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index 424354be0..e9f6fdce4 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -82,6 +82,57 @@ describe("String Assertions", () => { ])("isUrl $url => $expected", ({ url, expected }) => { expect(utils.isUrl(url)).toBe(expected); }); + + test.each([ + { + url: "https://github.com/radicle-dev/radicle-explorer/blob/master/img.png", + expected: + "https://github.com/radicle-dev/radicle-explorer/blob/master/img.png?raw=true", + }, + { + // Already has ?raw=true, leave it untouched. + url: "https://github.com/radicle-dev/radicle-explorer/blob/master/img.png?raw=true", + expected: + "https://github.com/radicle-dev/radicle-explorer/blob/master/img.png?raw=true", + }, + { + // Raw content host is already correct, leave it untouched. + url: "https://raw.githubusercontent.com/radicle-dev/radicle-explorer/master/img.png", + expected: + "https://raw.githubusercontent.com/radicle-dev/radicle-explorer/master/img.png", + }, + { + // An existing query string is preserved. + url: "https://github.com/radicle-dev/radicle-explorer/blob/master/img.png?token=abc", + expected: + "https://github.com/radicle-dev/radicle-explorer/blob/master/img.png?token=abc&raw=true", + }, + { + // The www subdomain is handled too. + url: "https://www.github.com/radicle-dev/radicle-explorer/blob/master/img.png", + expected: + "https://www.github.com/radicle-dev/radicle-explorer/blob/master/img.png?raw=true", + }, + { + // A "blob" segment that isn't in the //blob/ position is + // left untouched. + url: "https://github.com/radicle-dev/radicle-explorer/raw/master/blob/img.png", + expected: + "https://github.com/radicle-dev/radicle-explorer/raw/master/blob/img.png", + }, + { + // Non-GitHub URLs are returned unchanged. + url: "https://example.com/img.png", + expected: "https://example.com/img.png", + }, + { + // Not a valid URL, returned unchanged. + url: "not a url", + expected: "not a url", + }, + ])("canonicalizeGithubImageUrl $url => $expected", ({ url, expected }) => { + expect(utils.canonicalizeGithubImageUrl(url)).toEqual(expected); + }); }); describe("Parse Functions", () => {