From 37a2eff7166c36e8088f78003952139dd5ed83ec Mon Sep 17 00:00:00 2001 From: Dominic R Date: Fri, 21 Mar 2025 11:08:24 -0400 Subject: [PATCH] website: components: add multilinecodeblock component (#13179) * wip * wip * wip Signed-off-by: Dominic R wip Signed-off-by: Dominic R wip * wip * wip * move css to same folder Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer Co-authored-by: Jens Langhammer --- .../components/MultilineCodeblock/index.tsx | 188 ++++++++++++++++++ .../components/MultilineCodeblock/styles.css | 112 +++++++++++ 2 files changed, 300 insertions(+) create mode 100644 website/src/components/MultilineCodeblock/index.tsx create mode 100644 website/src/components/MultilineCodeblock/styles.css diff --git a/website/src/components/MultilineCodeblock/index.tsx b/website/src/components/MultilineCodeblock/index.tsx new file mode 100644 index 0000000000..091d69fa1f --- /dev/null +++ b/website/src/components/MultilineCodeblock/index.tsx @@ -0,0 +1,188 @@ +import React, { + ReactNode, + useState, + isValidElement, + useCallback, + useEffect, + useRef, +} from "react"; +import createDOMPurify from "dompurify"; +import "./styles.css"; + +type IntegrationsMultilineCodeblockProps = { + children: ReactNode; + className?: string; +}; + +type CopyButtonState = { + isCopied: boolean; + className: string; +}; + +// Configuration for allowed HTML tags in the sanitized output +const allowedTags = ["em", "code", "pre"]; + +/** + * Initializes DOMPurify safely for both browser and server environments + * @returns DOMPurify instance or null + */ +const getDOMPurify = () => { + if (typeof window !== "undefined") { + return createDOMPurify(window); + } + return null; +}; + +const domPurifyInstance = getDOMPurify(); + +/** + * Component for rendering secure code blocks with copy functionality + * - Safely sanitizes HTML content + * - Handles both string and JSX content + * - Provides copy-to-clipboard functionality + */ +const IntegrationsMultilineCodeblock: React.FC< + IntegrationsMultilineCodeblockProps +> = ({ children, className = "" }) => { + // State for managing copy button appearance and behavior + const [copyState, setCopyState] = useState({ + isCopied: false, + className: "", + }); + + // State for storing sanitized content after processing + const [sanitizedContent, setSanitizedContent] = useState( + null, + ); + + // Ref to access the actual DOM element for text extraction + const codeRef = useRef(null); + + /** + * Recursively converts React children to plain text string + * @param nodes - React children nodes to process + * @returns Flattened string representation + */ + const childrenAsString = useCallback((nodes: ReactNode): string => { + return React.Children.toArray(nodes).reduce((acc, node) => { + if (typeof node === "string") { + return acc + node; + } else if (isValidElement(node)) { + return acc + childrenAsString(node.props.children); + } else if (typeof node === "number") { + return acc + String(node); + } + return acc; + }, ""); + }, []); + + /** + * Sanitizes content while preserving allowed HTML tags + * @param children - React children to process + * @returns Sanitized HTML string + */ + const processContent = useCallback( + (children: ReactNode): string => { + const rawText = childrenAsString(children); + + // Client-side sanitization with DOMPurify + if (domPurifyInstance) { + return domPurifyInstance + .sanitize(rawText, { + ALLOWED_TAGS: allowedTags, + KEEP_CONTENT: true, + RETURN_TRUSTED_TYPE: false, + }) + .trim(); + } + + // Server-side fallback (no sanitization, will be re-processed client-side) + return rawText.trim(); + }, + [childrenAsString], + ); + + // Process content after component mounts to ensure browser environment + useEffect(() => { + setSanitizedContent(processContent(children)); + }, [children, processContent]); + + /** + * Handles copy-to-clipboard functionality + */ + const handleCopy = async (): Promise => { + // Get raw text content from DOM element, stripping all HTML tags + const textToCopy = codeRef.current?.textContent || ""; + + try { + await navigator.clipboard.writeText(textToCopy); + setCopyState({ + isCopied: true, + className: "integration-codeblock__copy-btn--copied", + }); + setTimeout(() => { + setCopyState((prev) => + prev.isCopied ? { isCopied: false, className: "" } : prev, + ); + }, 2000); + } catch (error) { + console.error("Failed to copy content:", error); + } + }; + + // SVG icon component for copy buttons + const Icon: React.FC<{ className: string; path: string }> = React.memo( + ({ className, path }) => ( + + + + ), + ); + + return ( +
+            {/* Attach ref to access rendered text content */}
+            
+                {typeof children === "string" ? (
+                    // Render sanitized HTML for string content
+                    
+                ) : (
+                    // Directly render JSX children (already safe)
+                    children
+                )}
+            
+
+            {/* Copy button with state-dependent styling */}
+            
+        
+ ); +}; + +export default IntegrationsMultilineCodeblock; diff --git a/website/src/components/MultilineCodeblock/styles.css b/website/src/components/MultilineCodeblock/styles.css new file mode 100644 index 0000000000..34f2608259 --- /dev/null +++ b/website/src/components/MultilineCodeblock/styles.css @@ -0,0 +1,112 @@ +/* Base styles */ +.integration-codeblock { + position: relative; + background: #f8f8f8; + border-radius: 8px; + font-family: "Menlo", "Consolas", monospace; + margin: 1.5rem 0; + padding: 1rem 2rem 1.5rem 1.5rem; + display: block; + overflow-x: auto; + font-size: 0.9em; + line-height: 1.5; + transition: box-shadow 0.2s ease; +} + +.integration-codeblock:hover { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.integration-codeblock code { + margin: 0; + padding: 0; + display: block; + background: transparent !important; + white-space: pre-wrap; +} + +/* Copy button */ +.integration-codeblock__copy-btn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + padding: 0.5rem; + border: none; + background: none; + cursor: pointer; + color: #666; + border-radius: 4px; + transition: all 0.2s ease; +} + +.integration-codeblock__copy-btn:hover { + background: rgba(0, 0, 0, 0.05); +} + +.integration-codeblock__copy-btn:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +.integration-codeblock__copy-btn[disabled] { + cursor: not-allowed; + opacity: 0.7; +} + +.integration-codeblock__copy-icons { + display: block; + position: relative; + width: 20px; + height: 20px; +} + +.integration-codeblock__copy-icon, +.integration-codeblock__copy-success-icon { + position: absolute; + top: 0; + left: 0; + transition: + opacity 0.2s ease, + transform 0.2s ease; +} + +.integration-codeblock__copy-success-icon { + opacity: 0; + transform: scale(0.8); +} + +.integration-codeblock__copy-btn--copied .integration-codeblock__copy-icon { + opacity: 0; + transform: scale(1.1); +} + +.integration-codeblock__copy-btn--copied + .integration-codeblock__copy-success-icon { + opacity: 1; + transform: scale(1); + color: #10b981; +} + +/* Dark mode */ +@media (prefers-color-scheme: dark) { + .integration-codeblock { + background: #1e1e1e; + color: #f8f8f8; + } + + .integration-codeblock:hover { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + } + + .integration-codeblock__copy-btn { + color: #999; + } + + .integration-codeblock__copy-btn:hover { + background: rgba(255, 255, 255, 0.05); + } + + .integration-codeblock__copy-success-icon { + color: #10b981; + } +}