mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-31 01:00:47 +08:00
Handle absolute markdown links with leading slash
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
BIN
Binary file not shown.
@@ -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: "/",
|
||||
|
||||
Reference in New Issue
Block a user