diff --git a/src/components/InlineMarkdown.svelte b/src/components/InlineMarkdown.svelte index 878bd005b..5ea4b5b0b 100644 --- a/src/components/InlineMarkdown.svelte +++ b/src/components/InlineMarkdown.svelte @@ -3,6 +3,7 @@ import markdown from "@app/lib/markdown"; import { twemoji } from "@app/lib/utils"; + import { activeUnloadedRouteStore } from "@app/lib/router"; import { Renderer } from "@app/lib/markdown"; export let content: string; @@ -13,7 +14,9 @@ const render = (content: string): string => dompurify.sanitize( markdown.parseInline(content, { - renderer: new Renderer(undefined, stripEmphasizedStyling), + renderer: new Renderer($activeUnloadedRouteStore, { + stripEmphasizedStyling, + }), }) as string, ); diff --git a/src/components/Markdown.svelte b/src/components/Markdown.svelte index 3e1920339..8383f8d5e 100644 --- a/src/components/Markdown.svelte +++ b/src/components/Markdown.svelte @@ -11,6 +11,7 @@ import ErrorModal from "@app/modals/ErrorModal.svelte"; import markdown from "@app/lib/markdown"; import { Renderer } from "@app/lib/markdown"; + import { activeUnloadedRouteStore } from "@app/lib/router"; import { highlight } from "@app/lib/syntax"; import { isUrl, @@ -22,8 +23,6 @@ import { mimes } from "@app/lib/file"; export let content: string; - // If present, resolve all relative links with respect to this URL - export let linkBaseUrl: string | undefined = undefined; export let path: string = "/"; export let rawPath: string; // If present, means we are in a preview context, @@ -63,7 +62,7 @@ } /** - * Do internal navigation on for clicks on anchor elements if possible + * Do internal navigation for clicks on anchor elements if possible */ function navigateInternalOnAnchor(event: MouseEvent) { if (router.useDefaultNavigation(event)) { @@ -94,7 +93,9 @@ function render(content: string): string { return dompurify.sanitize( markdown.parse(content, { - renderer: new Renderer(linkBaseUrl, false), + renderer: new Renderer($activeUnloadedRouteStore, { + stripEmphasizedStyling: false, + }), breaks, }) as string, ); diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index a7c13b103..371ee581b 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,4 +1,5 @@ import type { Tokens } from "marked"; +import type { Route } from "@app/lib/router"; import dompurify from "dompurify"; import katexMarkedExtension from "marked-katex-extension"; @@ -6,6 +7,8 @@ import markedLinkifyIt from "marked-linkify-it"; import { Marked, Renderer as BaseRenderer } from "marked"; import emojis from "@app/lib/emojis"; +import { routeToPath } from "@app/lib/router"; +import { canonicalize, isUrl } from "@app/lib/utils"; dompurify.setConfig({ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -101,16 +104,19 @@ const anchorMarkedExtension = { }; export class Renderer extends BaseRenderer { - #baseUrl: string | undefined; + #route: Route; #stripEmphasizedStyling: boolean | undefined; /** * If `baseUrl` is provided, all hrefs attributes in anchor tags, except those * starting with `#`, are resolved with respect to `baseUrl` */ - constructor(baseUrl: string | undefined, stripEmphasizedStyling: boolean) { + constructor( + activeUnloadedRoute: Route, + { stripEmphasizedStyling }: { stripEmphasizedStyling: boolean }, + ) { super(); - this.#baseUrl = baseUrl; + this.#route = activeUnloadedRoute; this.#stripEmphasizedStyling = stripEmphasizedStyling; } // Overwrites the rendering of heading tokens. @@ -139,15 +145,17 @@ export class Renderer extends BaseRenderer { if (href.startsWith("#")) { // By lowercasing we avoid casing mismatches, between headings and links. return `${text}`; - } else { - try { - href = new URL(href, this.#baseUrl).href; - } catch { - // Use original href value - } - - return `${text}`; } + + if ("path" in this.#route && this.#route.path && !isUrl(href)) { + href = routeToPath({ + ...this.#route, + path: canonicalize(href, this.#route.path), + route: undefined, + }); + } + + return `${text}`; } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e77fdda98..8ed5d8bd1 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -115,19 +115,23 @@ export function formatPublicExplorer( .replace("$path", fullPath.replace(`/nodes/${host}/${rid}`, "")); } -// Takes a path, eg. "../images/image.png", and a base from where to start resolving, e.g. "static/images/index.html". -// Returns the resolved path. +// Takes a path, eg. "../images/image.png", and a base from where to start +// resolving, e.g. "static/images/index.html". Returns the resolved path. export function canonicalize( path: string, base: string, origin = document.location.origin, ): string { - path = path.replace(/^\//, ""); // Remove leading slash - const finalPath = base - .split("/") - .slice(0, -1) // Remove file name. - .concat([path]) // Add image file path. - .join("/"); + let finalPath: string | undefined; + if (path.startsWith("/")) { + finalPath = new URL(path, origin).pathname; + } else { + finalPath = base + .split("/") + .slice(0, -1) // Remove file name. + .concat([path]) // Add image file path. + .join("/"); + } // URL is used to resolve relative paths, eg. `../../assets/image.png`. const url = new URL(finalPath, origin); diff --git a/src/views/projects/Source.svelte b/src/views/projects/Source.svelte index 810d4909d..750a2fd21 100644 --- a/src/views/projects/Source.svelte +++ b/src/views/projects/Source.svelte @@ -179,11 +179,9 @@
{#if blobResult.ok} diff --git a/src/views/projects/Source/Blob.svelte b/src/views/projects/Source/Blob.svelte index 71aefb473..8eb6868c0 100644 --- a/src/views/projects/Source/Blob.svelte +++ b/src/views/projects/Source/Blob.svelte @@ -7,7 +7,6 @@ import * as Syntax from "@app/lib/syntax"; import { isImagePath, isMarkdownPath, isSvgPath } from "@app/lib/utils"; import { lineNumbersGutter } from "@app/lib/syntax"; - import { routeToPath } from "@app/lib/router"; import Button from "@app/components/Button.svelte"; import CommitButton from "@app/views/projects/components/CommitButton.svelte"; @@ -20,8 +19,6 @@ export let baseUrl: BaseUrl; export let projectId: string; - export let peer: string | undefined; - export let revision: string | undefined; export let path: string; export let blob: Blob; export let highlighted: Syntax.Root | undefined; @@ -53,31 +50,6 @@ $: enablePreview = isMarkdown || isSvg; $: preview = enablePreview && selectedLineId === undefined; - let linkBaseUrl: string | undefined; - - $: { - if (!path || path === "/") { - // For the default root path, the `tree/` portion is omitted - // from the URL. This means that links cannot be resolved with respect - // to the current location. To work around this we provide path that - // results a fully expanded URL with which we can resolve all links in the - // Markdown. - linkBaseUrl = new URL( - routeToPath({ - resource: "project.source", - project: projectId, - node: baseUrl, - peer, - revision, - path: "README.md", - }), - window.origin, - ).href; - } else { - linkBaseUrl = undefined; - } - } - afterUpdate(() => { for (const item of document.getElementsByClassName("highlight")) { item.classList.remove("highlight"); @@ -217,7 +189,7 @@ {:else if preview && blob.content} {#if isMarkdown}
- +
{:else if isSvg}
diff --git a/tests/e2e/project.spec.ts b/tests/e2e/project.spec.ts index 4f88eb7b2..dfc1bc74f 100644 --- a/tests/e2e/project.spec.ts +++ b/tests/e2e/project.spec.ts @@ -468,6 +468,26 @@ test("external markdown link", async ({ context, page }) => { await expect(newPage).toHaveURL("https://example.com"); }); +test("absolute markdown link", async ({ page }) => { + await page.goto(`${markdownUrl}/tree/main/link-files.md`); + await page.getByRole("link", { name: "Absolute Link" }).click(); + await expect(page).toHaveURL( + `${markdownUrl}/tree/main/relative-files/linked-file.md`, + ); + await page.getByRole("link", { name: "nested file", exact: true }).click(); + await expect(page).toHaveURL( + `${markdownUrl}/tree/main/relative-files/nested-file.md`, + ); + await page.goBack(); + await page.getByRole("link", { name: "nested file with" }).click(); + await expect(page).toHaveURL( + `${markdownUrl}/tree/main/relative-files/nested-file.md`, + ); + await page.goBack(); + await page.getByRole("link", { name: "Back to link-files with" }).click(); + await expect(page).toHaveURL(`${markdownUrl}/tree/main/link-files.md`); +}); + test("internal file markdown link", async ({ page }) => { await page.goto(`${markdownUrl}/tree/main/link-files.md`); await page.getByRole("link", { name: "Markdown Cheatsheet" }).click(); diff --git a/tests/fixtures/repos/markdown.tar.bz2 b/tests/fixtures/repos/markdown.tar.bz2 index 99eb2ec01..a699da8f9 100644 Binary files a/tests/fixtures/repos/markdown.tar.bz2 and b/tests/fixtures/repos/markdown.tar.bz2 differ diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index aed88f67e..5365c7aaf 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -166,6 +166,12 @@ describe("Path Manipulation", () => { origin: "https://app.radicle.xyz", expected: "assets/images/tux.png", }, + { + imagePath: "/tux.md", + base: "/components/assets/README.md", + origin: "http://localhost:3000", + expected: "tux.md", + }, { imagePath: "assets/images/tux.png", base: "/",