mirror of https://github.com/usememos/memos.git
164 lines
4.6 KiB
TypeScript
164 lines
4.6 KiB
TypeScript
import copy from "copy-to-clipboard";
|
|
import DOMPurify from "dompurify";
|
|
import hljs from "highlight.js";
|
|
import { CopyIcon } from "lucide-react";
|
|
import { observer } from "mobx-react-lite";
|
|
import { useEffect, useMemo } from "react";
|
|
import toast from "react-hot-toast";
|
|
import { cn } from "@/lib/utils";
|
|
import { workspaceStore } from "@/store";
|
|
import MermaidBlock from "./MermaidBlock";
|
|
import { BaseProps } from "./types";
|
|
|
|
// Special languages that are rendered differently.
|
|
enum SpecialLanguage {
|
|
HTML = "__html",
|
|
MERMAID = "mermaid",
|
|
}
|
|
|
|
interface Props extends BaseProps {
|
|
language: string;
|
|
content: string;
|
|
}
|
|
|
|
const CodeBlock: React.FC<Props> = ({ language, content }: Props) => {
|
|
const formatedLanguage = useMemo(() => (language || "").toLowerCase() || "text", [language]);
|
|
|
|
// Users can set Markdown code blocks as `__html` to render HTML directly.
|
|
// Content is sanitized to prevent XSS attacks while preserving safe HTML.
|
|
if (formatedLanguage === SpecialLanguage.HTML) {
|
|
const sanitizedHTML = DOMPurify.sanitize(content, {
|
|
// Allow common safe HTML tags and attributes
|
|
ALLOWED_TAGS: [
|
|
"div",
|
|
"span",
|
|
"p",
|
|
"br",
|
|
"strong",
|
|
"b",
|
|
"em",
|
|
"i",
|
|
"u",
|
|
"s",
|
|
"strike",
|
|
"h1",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"h5",
|
|
"h6",
|
|
"blockquote",
|
|
"code",
|
|
"pre",
|
|
"ul",
|
|
"ol",
|
|
"li",
|
|
"dl",
|
|
"dt",
|
|
"dd",
|
|
"table",
|
|
"thead",
|
|
"tbody",
|
|
"tr",
|
|
"th",
|
|
"td",
|
|
"a",
|
|
"img",
|
|
"figure",
|
|
"figcaption",
|
|
"hr",
|
|
"small",
|
|
"sup",
|
|
"sub",
|
|
],
|
|
ALLOWED_ATTR: "href title alt src width height class id style target rel colspan rowspan".split(" "),
|
|
// Forbid dangerous attributes and tags
|
|
FORBID_ATTR: "onerror onload onclick onmouseover onfocus onblur onchange".split(" "),
|
|
FORBID_TAGS: "script iframe object embed form input button".split(" "),
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className="w-full overflow-auto my-2!"
|
|
dangerouslySetInnerHTML={{
|
|
__html: sanitizedHTML,
|
|
}}
|
|
/>
|
|
);
|
|
} else if (formatedLanguage === SpecialLanguage.MERMAID) {
|
|
return <MermaidBlock content={content} />;
|
|
}
|
|
|
|
const appTheme = workspaceStore.state.theme;
|
|
const isDarkTheme = appTheme.includes("dark");
|
|
|
|
useEffect(() => {
|
|
const dynamicImportStyle = async () => {
|
|
// Remove any existing highlight.js style
|
|
const existingStyle = document.querySelector("style[data-hljs-theme]");
|
|
if (existingStyle) {
|
|
existingStyle.remove();
|
|
}
|
|
|
|
try {
|
|
const cssModule = isDarkTheme
|
|
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
|
|
: await import("highlight.js/styles/github.css?inline");
|
|
|
|
// Create and inject the style
|
|
const style = document.createElement("style");
|
|
style.textContent = cssModule.default;
|
|
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
|
|
document.head.appendChild(style);
|
|
} catch (error) {
|
|
console.warn("Failed to load highlight.js theme:", error);
|
|
}
|
|
};
|
|
|
|
dynamicImportStyle();
|
|
}, [appTheme, isDarkTheme]);
|
|
|
|
const highlightedCode = useMemo(() => {
|
|
try {
|
|
const lang = hljs.getLanguage(formatedLanguage);
|
|
if (lang) {
|
|
return hljs.highlight(content, {
|
|
language: formatedLanguage,
|
|
}).value;
|
|
}
|
|
} catch {
|
|
// Skip error and use default highlighted code.
|
|
}
|
|
|
|
// Escape any HTML entities when rendering original content.
|
|
return Object.assign(document.createElement("span"), {
|
|
textContent: content,
|
|
}).innerHTML;
|
|
}, [formatedLanguage, content]);
|
|
|
|
const copyContent = () => {
|
|
copy(content);
|
|
toast.success("Copied to clipboard!");
|
|
};
|
|
|
|
return (
|
|
<div className="w-full my-1 bg-card border border-border rounded-md relative">
|
|
<div className="w-full px-2 py-0.5 flex flex-row justify-between items-center text-muted-foreground">
|
|
<span className="text-xs font-mono">{formatedLanguage}</span>
|
|
<CopyIcon className="w-3 h-auto cursor-pointer hover:text-foreground" onClick={copyContent} />
|
|
</div>
|
|
|
|
<div className="overflow-auto">
|
|
<pre className={cn("no-wrap overflow-auto", "w-full p-2 bg-muted/50 relative")}>
|
|
<code
|
|
className={cn(`language-${formatedLanguage}`, "block text-sm leading-5 text-foreground")}
|
|
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
|
></code>
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default observer(CodeBlock);
|