forked from treeverse/lakeFS
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support presigned image URLs in markdown (treeverse#7745)
* Support presigned image URLs in markdown * Migrate from react-markdown to rehype-react + remark-rehype
- Loading branch information
1 parent
cb1385e
commit a2e0128
Showing
9 changed files
with
14,529 additions
and
12,418 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
29 changes: 29 additions & 0 deletions
29
webui/src/pages/repositories/repository/fileRenderers/CustomMarkdownRenderer.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
28 changes: 0 additions & 28 deletions
28
webui/src/pages/repositories/repository/fileRenderers/CustomMarkdownRenderer.tsx
This file was deleted.
Oops, something went wrong.
1 change: 1 addition & 0 deletions
1
webui/src/pages/repositories/repository/fileRenderers/rehype-wrap.d.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
declare module "rehype-wrap"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
50 changes: 50 additions & 0 deletions
50
webui/src/pages/repositories/repository/fileRenderers/useMarkdownProcessor.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |