Handle absolute markdown links with leading slash

This commit is contained in:
Sebastian Martinez
2024-03-29 11:54:54 +01:00
committed by Rūdolfs Ošiņš
parent d6f3d17805
commit 2efd0ad58e
9 changed files with 68 additions and 56 deletions
+4 -1
View File
@@ -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,
);
</script>
+5 -4
View File
@@ -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,
);
+19 -11
View File
@@ -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 `<a ${title ? `title="${title}"` : ""} href="${href.toLowerCase()}">${text}</a>`;
} else {
try {
href = new URL(href, this.#baseUrl).href;
} catch {
// Use original href value
}
return `<a ${title ? `title="${title}"` : ""} href="${href}">${text}</a>`;
}
if ("path" in this.#route && this.#route.path && !isUrl(href)) {
href = routeToPath({
...this.#route,
path: canonicalize(href, this.#route.path),
route: undefined,
});
}
return `<a ${title ? `title="${title}"` : ""} href="${href}">${text}</a>`;
}
}
+12 -8
View File
@@ -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);
+1 -3
View File
@@ -179,11 +179,9 @@
<div class="column-right">
{#if blobResult.ok}
<BlobComponent
{path}
{baseUrl}
projectId={project.id}
{peer}
{revision}
{path}
blob={blobResult.blob}
highlighted={blobResult.highlighted}
rawPath={rawPath(tree.lastCommit.id)} />
+1 -29
View File
@@ -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/<revision>` 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}
<div style:padding="2rem">
<Markdown {rawPath} {path} {linkBaseUrl} content={blob.content} />
<Markdown content={blob.content} {rawPath} {path} />
</div>
{:else if isSvg}
<div style:margin="1rem 0" style:text-align="center">
+20
View File
@@ -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();
Binary file not shown.
+6
View File
@@ -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: "/",