Skip to content

Commit

Permalink
Support presigned image URLs in markdown (treeverse#7745)
Browse files Browse the repository at this point in the history
* Support presigned image URLs in markdown
* Migrate from react-markdown to rehype-react + remark-rehype
  • Loading branch information
eladlachmi authored and Victor Chen committed May 27, 2024
1 parent cb1385e commit a2e0128
Show file tree
Hide file tree
Showing 9 changed files with 14,529 additions and 12,418 deletions.
26,730 changes: 14,393 additions & 12,337 deletions webui/package-lock.json

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,15 @@
"react-router-dom": "^6.20.1",
"react-simple-code-editor": "^0.13.1",
"react-syntax-highlighter": "^15.5.0",
"rehype-raw": "^6.1.1",
"rehype-raw": "^7.0.0",
"rehype-react": "^8.0.0",
"rehype-wrap": "^1.1.0",
"remark": "^14.0.2",
"remark-gfm": "^3.0.1",
"remark-gfm": "^4.0.0",
"remark-html": "^15.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"unified": "^11.0.4",
"unist-util-visit": "^4.1.2",
"usehooks-ts": "^2.9.1",
"validator": "^13.11.0"
Expand Down Expand Up @@ -76,7 +81,6 @@
"sass": "^1.69.5",
"typescript": "^5.3.3",
"typesync": "^0.11.1",
"unified": "^10.1.2",
"vite": "^5.2.8",
"vite-plugin-eslint": "^1.8.1",
"vitest": "^1.5.0"
Expand Down
27 changes: 16 additions & 11 deletions webui/src/lib/remark-plugins/imageUriReplacer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ Text and whatever and hey look at this image:
const markdownWithReplacedImage = `# README
Text and whatever and hey look at this image:
![lakefs://image.png](${getImageUrl(TEST_REPO, TEST_REF, TEST_FILE_NAME)})
![lakefs://image.png](${await getImageUrl(TEST_REPO, TEST_REF, TEST_FILE_NAME, false)})
`;

const result = await remark()
.use(imageUriReplacer, {
repo: TEST_REPO,
ref: TEST_REF,
path: "",
presign: false,
})
.process(markdown);
expect(result.toString()).toEqual(markdownWithReplacedImage);
Expand All @@ -43,22 +44,22 @@ Text and whatever and hey look at this image:
const markdownWithReplacedImage = `# README
Text and whatever and hey look at this image:
![lakefs://image.png](${getImageUrl(
![lakefs://image.png](${await getImageUrl(
TEST_REPO,
TEST_REF,
`${ADDITIONAL_PATH}/${TEST_FILE_NAME}`
`${ADDITIONAL_PATH}/${TEST_FILE_NAME}`,
false,
)})
`;

const result = await remark()
.use([
imageUriReplacer,
.use(imageUriReplacer,
{
repo: TEST_REPO,
ref: TEST_REF,
path: "",
},
])
presign: false,
})
.process(markdown);
expect(result.toString()).toEqual(markdownWithReplacedImage);
});
Expand All @@ -73,14 +74,15 @@ Text and whatever and hey look at this image:
const markdownWithReplacedImage = `# README
Text and whatever and hey look at this image:
![lakefs://image.png](${getImageUrl(TEST_REPO, TEST_REF, TEST_FILE_NAME)})
![lakefs://image.png](${await getImageUrl(TEST_REPO, TEST_REF, TEST_FILE_NAME, false)})
`;

const result = await remark()
.use(imageUriReplacer, {
repo: TEST_REPO,
ref: TEST_REF,
path: "",
presign: false,
})
.process(markdown);
expect(result.toString()).toEqual(markdownWithReplacedImage);
Expand All @@ -96,14 +98,15 @@ Text and whatever and hey look at this image:
const markdownWithReplacedImage = `# README
Text and whatever and hey look at this image:
![lakefs://image.png](${getImageUrl(TEST_REPO, TEST_REF, TEST_FILE_NAME)})
![lakefs://image.png](${await getImageUrl(TEST_REPO, TEST_REF, TEST_FILE_NAME, false)})
`;

const result = await remark()
.use(imageUriReplacer, {
repo: TEST_REPO,
ref: TEST_REF,
path: "",
presign: false,
})
.process(markdown);
expect(result.toString()).toEqual(markdownWithReplacedImage);
Expand All @@ -120,10 +123,11 @@ Text and whatever and hey look at this image:
const markdownWithReplacedImage = `# README
Text and whatever and hey look at this image:
![lakefs://image.png](${getImageUrl(
![lakefs://image.png](${await getImageUrl(
TEST_REPO,
TEST_REF,
`${markdownFilePath}/${TEST_FILE_NAME}`
`${markdownFilePath}/${TEST_FILE_NAME}`,
false,
)})
`;

Expand All @@ -132,6 +136,7 @@ Text and whatever and hey look at this image:
repo: TEST_REPO,
ref: TEST_REF,
path: `${markdownFilePath}/test.md`,
presign: false,
})
.process(markdown);
expect(result.toString()).toEqual(markdownWithReplacedImage);
Expand Down
42 changes: 30 additions & 12 deletions webui/src/lib/remark-plugins/imageUriReplacer.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,69 @@
import { visit } from "unist-util-visit";
import type { Node } from "unist";
import type { Root } from "mdast";
import type { Plugin } from "unified";
import {objects} from "../api";

type ImageUriReplacerOptions = {
repo: string;
ref: string;
path: string;
presign: boolean;
};

const ABSOLUTE_URL_REGEX = /^(https?):\/\/.*/;
const qs = (queryParts: { [key: string]: string }) => {
const parts = Object.keys(queryParts).map((key) => [key, queryParts[key]]);
return new URLSearchParams(parts).toString();
};
export const getImageUrl = (
export const getImageUrl = async (
repo: string,
ref: string,
path: string
): string => {
path: string,
presign: boolean,
): Promise<string> => {
if (presign) {
try {
const obj = await objects.getStat(repo, ref, path, true);
return obj.physical_address;
} catch(e) {
console.error("failed to fetch presigned URL", e);
return ""
}
}

const query = qs({ path });
return `/api/v1/repositories/${encodeURIComponent(
repo
)}/refs/${encodeURIComponent(ref)}/objects?${query}`;
};

const imageUriReplacer: Plugin<[ImageUriReplacerOptions], Root> =
(options) => (tree) => {
visit(tree, "image", (node: Node & { url: string }) => {
const imageUriReplacer =
(options: ImageUriReplacerOptions) => async (tree: Node) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promises: any[] = [];
visit(tree, "image", visitor);
await Promise.all(promises);

function visitor(node: Node & { url: string }) {
if (node.url.startsWith("lakefs://")) {
const [repo, ref, ...imgPath] = node.url.split("/").slice(2);
node.url = getImageUrl(repo, ref, imgPath.join("/"));
const p = getImageUrl(repo, ref, imgPath.join("/"), options.presign).then((url) => node.url = url);
promises.push(p);
} else if (!node.url.match(ABSOLUTE_URL_REGEX)) {
// If the image is not an absolute URL, we assume it's a relative path
// relative to repo and ref
if (node.url.startsWith("/")) {
node.url = node.url.slice(1);
node.url = node.url.slice(1);
}
// relative to MD file location
if (node.url.startsWith("./")) {
node.url = `${options.path.split("/").slice(0, -1)}/${node.url.slice(
2
)}`;
}
node.url = getImageUrl(options.repo, options.ref, node.url);
const p = getImageUrl(options.repo, options.ref, node.url, options.presign).then((url) => node.url = url);
promises.push(p);
}
});
}
};

export default imageUriReplacer;
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { github as syntaxHighlightStyle } from "react-syntax-highlighter/dist/esm/styles/hljs";

export const CustomMarkdownCodeComponent = ({
inline,
className,
children,
...props
}) => {
const hasLang = /language-(\w+)/.exec(className || "");

return !inline && hasLang ? (
<SyntaxHighlighter
style={syntaxHighlightStyle}
language={hasLang[1]}
PreTag="div"
className="codeStyle"
showLineNumbers={false}
useInlineStyles={true}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
};

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "rehype-wrap";
30 changes: 3 additions & 27 deletions webui/src/pages/repositories/repository/fileRenderers/simple.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,18 @@ import { humanSize } from "../../../../lib/components/repository/tree";
import { useAPI } from "../../../../lib/hooks/api";
import { objects, qs } from "../../../../lib/api";
import { AlertError, Loading } from "../../../../lib/components/controls";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkHtml from "remark-html";
import rehypeRaw from "rehype-raw";
import SyntaxHighlighter from "react-syntax-highlighter";
import { githubGist as syntaxHighlightStyle } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { IpynbRenderer as NbRenderer } from "react-ipynb-renderer";
import { guessLanguage } from "./index";
import { CustomMarkdownRenderer } from "./CustomMarkdownRenderer";
import {
RendererComponent,
RendererComponentWithText,
RendererComponentWithTextCallback,
} from "./types";
import imageUriReplacer from "../../../../lib/remark-plugins/imageUriReplacer";

import "react-ipynb-renderer/dist/styles/default.css";
import { useMarkdownProcessor } from "./useMarkdownProcessor";

export const ObjectTooLarge: FC<RendererComponent> = ({ path, sizeBytes }) => {
return (
Expand Down Expand Up @@ -76,28 +71,9 @@ export const MarkdownRenderer: FC<RendererComponentWithText> = ({
repoId,
refId,
path,
presign = false,
}) => {
return (
<ReactMarkdown
className="object-viewer-markdown"
components={CustomMarkdownRenderer}
remarkPlugins={[
[
imageUriReplacer,
{
repo: repoId,
ref: refId,
path,
},
],
remarkGfm,
remarkHtml,
]}
rehypePlugins={[rehypeRaw]}
>
{text}
</ReactMarkdown>
);
return useMarkdownProcessor(text, repoId, refId, path, presign);
};

export const TextRenderer: FC<RendererComponentWithText> = ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {Fragment, createElement, useEffect, useState} from 'react'
import * as prod from 'react/jsx-runtime'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import remarkGfm from "remark-gfm";
import remarkHtml from "remark-html";
import rehypeRaw from "rehype-raw";
import rehypeReact from 'rehype-react';
import rehypeWrap from "rehype-wrap";
import {unified} from "unified";
import imageUriReplacer from "../../../../lib/remark-plugins/imageUriReplacer";
import {CustomMarkdownCodeComponent} from "./CustomMarkdownRenderer";

// @ts-expect-error: the react types are missing.
const options: Options = {Fragment: prod.Fragment, jsx: prod.jsx, jsxs: prod.jsxs, passNode: true};
options.components = {
code: CustomMarkdownCodeComponent,
};

/**
* @param {string} text
* @returns {JSX.Element}
*/
export function useMarkdownProcessor(text: string, repoId: string, refId: string, path: string, presign: boolean): JSX.Element {
const [content, setContent] = useState(createElement(Fragment));

useEffect(() => {
(async () => {
const file = await unified()
.use(remarkParse)
.use(imageUriReplacer, {
repo: repoId,
ref: refId,
path,
presign,
})
.use(remarkGfm)
.use(remarkHtml)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeReact, options)
.use(rehypeWrap, {wrapper: "div.object-viewer-markdown"})
.process(text);

setContent(file.result);
})();
}, [text]);

return content;
}

0 comments on commit a2e0128

Please sign in to comment.