diff --git a/packages/website/app/inspect/layout.tsx b/packages/website/app/inspect/layout.tsx new file mode 100644 index 000000000..44aae8fac --- /dev/null +++ b/packages/website/app/inspect/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; + +const title = "Inspect"; +const description = + "Paste React Grab output to visualize element context in raw and formatted views."; +const ogImageUrl = `https://react-grab.com/api/og?title=${encodeURIComponent(title)}&subtitle=${encodeURIComponent(description)}`; + +export const metadata: Metadata = { + title: `${title} | React Grab`, + description, + openGraph: { + title: `${title} | React Grab`, + description, + url: "https://react-grab.com/inspect", + siteName: "React Grab", + images: [ + { + url: ogImageUrl, + width: 1200, + height: 630, + alt: `React Grab - ${title}`, + }, + ], + locale: "en_US", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: `${title} | React Grab`, + description, + images: [ogImageUrl], + }, + alternates: { + canonical: "https://react-grab.com/inspect", + }, +}; + +interface InspectLayoutProps { + children: React.ReactNode; +} + +const InspectLayout = (props: InspectLayoutProps) => { + return props.children; +}; + +InspectLayout.displayName = "InspectLayout"; + +export default InspectLayout; diff --git a/packages/website/app/inspect/page.tsx b/packages/website/app/inspect/page.tsx new file mode 100644 index 000000000..db5f63eba --- /dev/null +++ b/packages/website/app/inspect/page.tsx @@ -0,0 +1,476 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { ReactGrabLogo } from "@/components/react-grab-logo"; +import { cn } from "@/utils/cn"; +import { highlightCode } from "@/lib/shiki"; +import { ArrowLeft, Clipboard, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import prettyMs from "pretty-ms"; + +const REACT_GRAB_MIME_TYPE = "application/x-react-grab"; +const STACK_FRAME_PATTERN = + /^\s+in\s+(\S+?)(?:\s+\(at\s+([^:)]+?)(?::(\d+))?(?::(\d+))?\))?$/; + +const TABS = ["raw", "formatted"] as const; +type Tab = (typeof TABS)[number]; + +interface ReactGrabEntry { + tagName?: string; + componentName?: string; + content: string; + commentText?: string; +} + +interface ReactGrabMetadata { + version: string; + content: string; + entries: ReactGrabEntry[]; + timestamp: number; +} + +interface ParsedStackFrame { + componentName: string; + filePath: string | null; + lineNumber: number | null; + columnNumber: number | null; +} + +interface ParsedContent { + htmlSnippet: string; + stackFrames: ParsedStackFrame[]; +} + +const parseReactGrabMetadata = ( + jsonString: string, +): ReactGrabMetadata | null => { + try { + const parsed = JSON.parse(jsonString); + if ( + parsed && + typeof parsed.content === "string" && + Array.isArray(parsed.entries) + ) { + return parsed; + } + return null; + } catch { + return null; + } +}; + +const parseEntryContent = (content: string): ParsedContent => { + const lines = content.split("\n"); + const snippetLines: string[] = []; + const stackFrames: ParsedStackFrame[] = []; + + for (const line of lines) { + const match = STACK_FRAME_PATTERN.exec(line); + if (match) { + stackFrames.push({ + componentName: match[1], + filePath: match[2] ?? null, + lineNumber: match[3] ? Number(match[3]) : null, + columnNumber: match[4] ? Number(match[4]) : null, + }); + } else { + snippetLines.push(line); + } + } + + return { + htmlSnippet: snippetLines.join("\n").trimEnd(), + stackFrames, + }; +}; + +const formatTimestamp = (timestamp: number): string => { + const elapsed = Date.now() - timestamp; + if (elapsed < 1000) return "just now"; + return `${prettyMs(elapsed, { compact: true })} ago`; +}; + +const useIsMac = (): boolean => { + const [isMac, setIsMac] = useState(false); + + useEffect(() => { + setIsMac(navigator.platform.toUpperCase().includes("MAC")); + }, []); + + return isMac; +}; + +const ElementTag = (props: { children: string }) => ( + + {props.children} + +); + +ElementTag.displayName = "ElementTag"; + +const InfoRow = (props: { + label: string; + children: React.ReactNode; + borderTop?: boolean; +}) => ( +
+ + {props.label} + + {props.children} +
+); + +InfoRow.displayName = "InfoRow"; + +interface FormattedEntryCardProps { + entry: ReactGrabEntry; + highlightedSnippet: string; + parsedContent: ParsedContent; + entryIndex: number; + totalEntries: number; +} + +const FormattedEntryCard = (props: FormattedEntryCardProps) => { + const hasStack = props.parsedContent.stackFrames.length > 0; + const firstFrame = hasStack ? props.parsedContent.stackFrames[0] : null; + + return ( +
+ {props.totalEntries > 1 && ( +
+ Entry {props.entryIndex + 1} of {props.totalEntries} +
+ )} + + {props.entry.tagName && ( + + {`<${props.entry.tagName}>`} + + )} + + {props.entry.componentName && ( + + + {props.entry.componentName} + + + )} + + {firstFrame?.filePath && ( + + + {firstFrame.filePath} + {firstFrame.lineNumber !== null && ( + + :{firstFrame.lineNumber} + {firstFrame.columnNumber !== null && + `:${firstFrame.columnNumber}`} + + )} + + + )} + + {props.entry.commentText && ( + + + “{props.entry.commentText}” + + + )} + + {props.parsedContent.htmlSnippet && ( +
+
+ HTML +
+
+
+ )} + + {hasStack && ( +
+
+ Stack +
+
+ {props.parsedContent.stackFrames.map((frame, frameIndex) => ( +
+ + {frame.componentName} + + {frame.filePath && ( + + {frame.filePath} + {frame.lineNumber !== null && `:${frame.lineNumber}`} + {frame.columnNumber !== null && `:${frame.columnNumber}`} + + )} +
+ ))} +
+
+ )} +
+ ); +}; + +FormattedEntryCard.displayName = "FormattedEntryCard"; + +interface HighlightedEntryData { + entry: ReactGrabEntry; + parsedContent: ParsedContent; + highlightedSnippet: string; +} + +const InspectPage = () => { + const [pastedContent, setPastedContent] = useState(null); + const [metadata, setMetadata] = useState(null); + const [activeTab, setActiveTab] = useState("raw"); + const [highlightedFullContent, setHighlightedFullContent] = useState< + string | null + >(null); + const [highlightedEntryData, setHighlightedEntryData] = useState< + HighlightedEntryData[] + >([]); + const isMac = useIsMac(); + + const handleClear = useCallback(() => { + setPastedContent(null); + setMetadata(null); + setHighlightedFullContent(null); + setHighlightedEntryData([]); + setActiveTab("raw"); + }, []); + + useEffect(() => { + const handlePaste = (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + if (!clipboardData) return; + + const reactGrabData = clipboardData.getData(REACT_GRAB_MIME_TYPE); + const plainText = clipboardData.getData("text/plain"); + + if (!reactGrabData && !plainText) return; + + event.preventDefault(); + + const parsedMetadata = reactGrabData + ? parseReactGrabMetadata(reactGrabData) + : null; + + const content = plainText || parsedMetadata?.content || ""; + setPastedContent(content); + setMetadata(parsedMetadata); + setActiveTab(parsedMetadata ? "formatted" : "raw"); + }; + + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); + }, []); + + useEffect(() => { + if (!pastedContent) { + setHighlightedFullContent(null); + setHighlightedEntryData([]); + return; + } + + const highlightAsync = async () => { + const fullHtml = await highlightCode({ + code: pastedContent, + lang: "html", + }); + setHighlightedFullContent(fullHtml); + + if (metadata?.entries.length) { + const results = await Promise.all( + metadata.entries.map(async (entry) => { + const parsedContent = parseEntryContent(entry.content); + const highlightedSnippet = parsedContent.htmlSnippet + ? await highlightCode({ + code: parsedContent.htmlSnippet, + lang: "html", + }) + : ""; + return { entry, parsedContent, highlightedSnippet }; + }), + ); + setHighlightedEntryData(results); + } + }; + + highlightAsync(); + }, [metadata, pastedContent]); + + const hasContent = Boolean(pastedContent); + + if (!hasContent) { + return ( +
+
+ + + + +
+

Inspect

+

+ Paste React Grab output anywhere on this page +

+
+ +
+ + + {isMac ? "⌘" : "Ctrl"} + V + +
+
+
+ ); + } + + return ( +
+
+ + + Back to home + + +
+ + + +
+ +
+
+ {TABS.map((tab) => ( + + ))} +
+ + {metadata && ( +
+ {metadata.version && ( + + v{metadata.version} + + )} + {metadata.timestamp > 0 && ( + {formatTimestamp(metadata.timestamp)} + )} +
+ )} + +
+ + +
+ + {activeTab === "raw" && ( +
+ {highlightedFullContent ? ( +
+ ) : ( +
+                {pastedContent}
+              
+ )} +
+ )} + + {activeTab === "formatted" && ( +
+ {highlightedEntryData.length > 0 ? ( + highlightedEntryData.map((entryData, index) => ( + + )) + ) : metadata ? ( +
+ Loading… +
+ ) : ( +
+
+ Paste React Grab output (with{" "} + + application/x-react-grab + {" "} + metadata) for the structured view. +
+
+
+                    {pastedContent}
+                  
+
+
+ )} +
+ )} +
+
+ ); +}; + +InspectPage.displayName = "InspectPage"; + +export default InspectPage;