Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support presigned image URLs in markdown #7745

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Loading