diff --git a/README.md b/README.md index a3947d42c..531f41c87 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,21 @@ [![version](https://img.shields.io/npm/v/react-grab?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-grab) [![downloads](https://img.shields.io/npm/dt/react-grab.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-grab) -Select context for coding agents directly from your website +Copy any UI element for your agent. -How? Point at any element and press **⌘C** (Mac) or **Ctrl+C** (Windows/Linux) to copy the file name, React component, and HTML source code. - -It makes tools like Cursor, Claude Code, Copilot run up to [**3× faster**](https://react-grab.com/blog/intro) and more accurate. +React Grab points agents to the actual source behind each selection, so edits are [**2× faster**](https://github.com/aidenybai/react-grab-bench) and more accurate. ### [Try out a demo! →](https://react-grab.com) -## Install +## Quick Start -Run this command at your project root (where `next.config.ts` or `vite.config.ts` is located): +Run this at your project root: ```bash npx grab@latest init ``` -## Connect to MCP +Optional: connect React Grab to your agents with MCP: ```bash npx grab@latest add mcp @@ -27,27 +25,46 @@ npx grab@latest add mcp ## Usage -Once installed, hover over any UI element in your browser and press: +Start your dev server, open your app, then hover any UI element and press: - **⌘C** (Cmd+C) on Mac - **Ctrl+C** on Windows/Linux -This copies the element's context (file name, React component, and HTML source code) to your clipboard ready to paste into your coding agent. For example: +React Grab copies that context to your clipboard, ready to paste into your agent. -```js +## What Agents Get + +React Grab includes the details agents need to edit the right file: + +- the rendered HTML for the element +- the React component name +- the source file and line number +- the nearby source code, when available +- the component stack + +Example: + +```txt Forgot your password? -in LoginForm at components/login-form.tsx:46:19 + +// components/login-form.tsx:46 + 45|
+> 46| + 47| Forgot your password? + 48| + + in LoginForm (at components/login-form.tsx:46:19) ``` ## Manual Installation -If you're using a React framework or build tool, view instructions below: +If you cannot use the CLI, install React Grab manually for your framework: #### Next.js (App router) -Add this inside of your `app/layout.tsx`: +Add this inside your `app/layout.tsx`: ```jsx import Script from "next/script"; @@ -190,19 +207,13 @@ actions: [ See [`packages/react-grab/src/types.ts`](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) for the full `Plugin`, `PluginHooks`, and `PluginConfig` interfaces. -## Resources & Contributing Back - -Want to try it out? Check out [our demo](https://react-grab.com). - -Looking to contribute back? Check out the [Contributing Guide](https://github.com/aidenybai/react-grab/blob/main/CONTRIBUTING.md). - -Want to talk to the community? Hop in our [Discord](https://discord.com/invite/G7zxfUzkm7) and share your ideas and what you've built with React Grab. - -Find a bug? Head over to our [issue tracker](https://github.com/aidenybai/react-grab/issues) and we'll do our best to help. We love pull requests, too! - -We expect all contributors to abide by the terms of our [Code of Conduct](https://github.com/aidenybai/react-grab/blob/main/.github/CODE_OF_CONDUCT.md). +## Links -[**→ Start contributing on GitHub**](https://github.com/aidenybai/react-grab/blob/main/CONTRIBUTING.md) +- [Demo](https://react-grab.com) +- [Contributing Guide](https://github.com/aidenybai/react-grab/blob/main/CONTRIBUTING.md) +- [Discord](https://discord.com/invite/G7zxfUzkm7) +- [Issue tracker](https://github.com/aidenybai/react-grab/issues) +- [Code of Conduct](https://github.com/aidenybai/react-grab/blob/main/.github/CODE_OF_CONDUCT.md) ### License diff --git a/apps/storybook/README.md b/apps/storybook/README.md index 1a2aad8b6..fa9fea560 100644 --- a/apps/storybook/README.md +++ b/apps/storybook/README.md @@ -1,6 +1,8 @@ # @react-grab/storybook -Visual playground for React Grab's overlay system - renders the full `ReactGrabRenderer` on a sample page with Storybook controls for every user-facing state. +Internal playground for React Grab's overlay UI. + +Storybook renders the full `ReactGrabRenderer` against mocked states and realistic playground pages, so overlay states can be inspected without running the e2e app. Uses [Storybook 10](https://storybook.js.org/) with [`storybook-solidjs-vite`](https://github.com/nicolo-ribaudo/storybook-solidjs-vite). @@ -29,13 +31,13 @@ Static build output is written to `storybook-static/`. ``` apps/storybook/ ├── .storybook/ -│ ├── main.ts ← Storybook config -│ └── preview.tsx ← global parameters & CSS import +│ ├── main.ts Storybook config +│ └── preview.tsx global parameters and CSS import └── stories/ - ├── fixtures.ts ← preset comment items + menu actions - ├── noop.ts ← no-op callback for handlers - ├── *.stories.tsx ← Component stories (mock renderer states) - └── playground/ ← Ad-hoc scenarios with real init() running + ├── fixtures.ts preset comment items and menu actions + ├── noop.ts no-op callback for handlers + ├── *.stories.tsx component stories with mocked renderer states + └── playground/ scenarios with real init() running ├── composite-dashboard.stories.tsx ├── freeze-demo.stories.tsx └── live-updates.stories.tsx @@ -43,9 +45,9 @@ apps/storybook/ ## Two kinds of stories -**Component stories** (toolbar, selection-label, context-menu, comments-dropdown, renderer) render the overlay with mocked props. They're single sources of truth for every user-facing state: idle, context menu, comment input, pending dismiss, etc. +**Component stories** render toolbar, selection label, context menu, comments dropdown, and renderer states with mocked props. Use these to inspect specific UI states such as idle, context menu, comment input, and pending dismiss. -**Playground stories** import `react-grab` for its side effect, so `init()` actually runs and hooks into the story DOM. They replace the former `apps/gym` and exist for ad-hoc hover/grab testing against realistic fixtures: +**Playground stories** import `react-grab` for its side effect, so `init()` runs against the story DOM. Use these for ad-hoc hover and grab testing against realistic fixtures: - **Composite Dashboard** - sidebar, metric cards, chart, and data table for dense-DOM selection testing - **Freeze Demo** - bouncing animated timer for verifying freeze-animations + freeze-updates diff --git a/packages/cli/README.md b/packages/cli/README.md index a4a394620..0fcab7812 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,8 @@ # @react-grab/cli -Interactive CLI to install and configure React Grab in your project. +CLI for installing React Grab, configuring its activation behavior, and connecting agents through MCP. + +The CLI detects supported React projects, applies the dev-only setup, and can reconfigure an existing installation without hand-editing framework files. ## Quick Start @@ -12,7 +14,7 @@ npx grab@latest init ### `grab init` -Initialize React Grab in your project. Auto-detects your framework and applies the necessary changes. +Install React Grab in the current project. The CLI auto-detects the framework and applies the required development-only integration. ```bash npx grab@latest init @@ -29,7 +31,7 @@ npx grab@latest init ### `grab add` -Connect React Grab to your coding agent via MCP. +Configure the React Grab MCP server for your agent. ```bash npx grab@latest add mcp @@ -42,7 +44,7 @@ npx grab@latest add mcp ### `grab remove` -Disconnect React Grab from your coding agent. +Remove the MCP connection from your agent. ```bash npx grab@latest remove mcp @@ -55,7 +57,7 @@ npx grab@latest remove mcp ### `grab configure` -Configure React Grab options. Runs an interactive wizard when called without flags. +Update React Grab options. Runs an interactive wizard when called without flags. ```bash npx grab@latest configure @@ -96,9 +98,10 @@ npx grab@latest configure ## Supported Frameworks -| Framework | Detection | -| ---------------------- | ------------------------------------- | -| Next.js (App Router) | `next.config.ts` + `app/` directory | -| Next.js (Pages Router) | `next.config.ts` + `pages/` directory | -| Vite | `vite.config.ts` | -| Webpack | `webpack.config.*` | +The CLI currently configures: + +- Next.js App Router +- Next.js Pages Router +- Vite +- TanStack Start +- Webpack diff --git a/packages/grab/README.md b/packages/grab/README.md index 44bc23190..0ac59cc70 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -3,23 +3,21 @@ [![version](https://img.shields.io/npm/v/grab?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/grab) [![downloads](https://img.shields.io/npm/dt/grab.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/grab) -Select context for coding agents directly from your website +Copy any UI element for your agent. -How? Point at any element and press **⌘C** (Mac) or **Ctrl+C** (Windows/Linux) to copy the file name, React component, and HTML source code. - -It makes tools like Cursor, Claude Code, Copilot run up to [**3× faster**](https://react-grab.com/blog/intro) and more accurate. +React Grab points agents to the actual source behind each selection, so edits are [**2× faster**](https://github.com/aidenybai/react-grab-bench) and more accurate. ### [Try out a demo! →](https://react-grab.com) -## Install +## Quick Start -Run this command at your project root (where `next.config.ts` or `vite.config.ts` is located): +Run this at your project root: ```bash npx grab@latest init ``` -## Connect to MCP +Optional: connect React Grab to your agents with MCP: ```bash npx grab@latest add mcp @@ -27,27 +25,46 @@ npx grab@latest add mcp ## Usage -Once installed, hover over any UI element in your browser and press: +Start your dev server, open your app, then hover any UI element and press: - **⌘C** (Cmd+C) on Mac - **Ctrl+C** on Windows/Linux -This copies the element's context (file name, React component, and HTML source code) to your clipboard ready to paste into your coding agent. For example: +React Grab copies that context to your clipboard, ready to paste into your agent. -```js +## What Agents Get + +React Grab includes the details agents need to edit the right file: + +- the rendered HTML for the element +- the React component name +- the source file and line number +- the nearby source code, when available +- the component stack + +Example: + +```txt Forgot your password? -in LoginForm at components/login-form.tsx:46:19 + +// components/login-form.tsx:46 + 45|
+> 46| + 47| Forgot your password? + 48| + + in LoginForm (at components/login-form.tsx:46:19) ``` ## Manual Installation -If you're using a React framework or build tool, view instructions below: +If you cannot use the CLI, install React Grab manually for your framework: #### Next.js (App router) -Add this inside of your `app/layout.tsx`: +Add this inside your `app/layout.tsx`: ```jsx import Script from "next/script"; @@ -190,19 +207,13 @@ actions: [ See [`packages/react-grab/src/types.ts`](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) for the full `Plugin`, `PluginHooks`, and `PluginConfig` interfaces. -## Resources & Contributing Back - -Want to try it out? Check out [our demo](https://react-grab.com). - -Looking to contribute back? Check out the [Contributing Guide](https://github.com/aidenybai/react-grab/blob/main/CONTRIBUTING.md). - -Want to talk to the community? Hop in our [Discord](https://discord.com/invite/G7zxfUzkm7) and share your ideas and what you've built with React Grab. - -Find a bug? Head over to our [issue tracker](https://github.com/aidenybai/react-grab/issues) and we'll do our best to help. We love pull requests, too! - -We expect all contributors to abide by the terms of our [Code of Conduct](https://github.com/aidenybai/react-grab/blob/main/.github/CODE_OF_CONDUCT.md). +## Links -[**→ Start contributing on GitHub**](https://github.com/aidenybai/react-grab/blob/main/CONTRIBUTING.md) +- [Demo](https://react-grab.com) +- [Contributing Guide](https://github.com/aidenybai/react-grab/blob/main/CONTRIBUTING.md) +- [Discord](https://discord.com/invite/G7zxfUzkm7) +- [Issue tracker](https://github.com/aidenybai/react-grab/issues) +- [Code of Conduct](https://github.com/aidenybai/react-grab/blob/main/.github/CODE_OF_CONDUCT.md) ### License diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index a3947d42c..531f41c87 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -3,23 +3,21 @@ [![version](https://img.shields.io/npm/v/react-grab?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-grab) [![downloads](https://img.shields.io/npm/dt/react-grab.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-grab) -Select context for coding agents directly from your website +Copy any UI element for your agent. -How? Point at any element and press **⌘C** (Mac) or **Ctrl+C** (Windows/Linux) to copy the file name, React component, and HTML source code. - -It makes tools like Cursor, Claude Code, Copilot run up to [**3× faster**](https://react-grab.com/blog/intro) and more accurate. +React Grab points agents to the actual source behind each selection, so edits are [**2× faster**](https://github.com/aidenybai/react-grab-bench) and more accurate. ### [Try out a demo! →](https://react-grab.com) -## Install +## Quick Start -Run this command at your project root (where `next.config.ts` or `vite.config.ts` is located): +Run this at your project root: ```bash npx grab@latest init ``` -## Connect to MCP +Optional: connect React Grab to your agents with MCP: ```bash npx grab@latest add mcp @@ -27,27 +25,46 @@ npx grab@latest add mcp ## Usage -Once installed, hover over any UI element in your browser and press: +Start your dev server, open your app, then hover any UI element and press: - **⌘C** (Cmd+C) on Mac - **Ctrl+C** on Windows/Linux -This copies the element's context (file name, React component, and HTML source code) to your clipboard ready to paste into your coding agent. For example: +React Grab copies that context to your clipboard, ready to paste into your agent. -```js +## What Agents Get + +React Grab includes the details agents need to edit the right file: + +- the rendered HTML for the element +- the React component name +- the source file and line number +- the nearby source code, when available +- the component stack + +Example: + +```txt Forgot your password? -in LoginForm at components/login-form.tsx:46:19 + +// components/login-form.tsx:46 + 45|
+> 46| + 47| Forgot your password? + 48| + + in LoginForm (at components/login-form.tsx:46:19) ``` ## Manual Installation -If you're using a React framework or build tool, view instructions below: +If you cannot use the CLI, install React Grab manually for your framework: #### Next.js (App router) -Add this inside of your `app/layout.tsx`: +Add this inside your `app/layout.tsx`: ```jsx import Script from "next/script"; @@ -190,19 +207,13 @@ actions: [ See [`packages/react-grab/src/types.ts`](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) for the full `Plugin`, `PluginHooks`, and `PluginConfig` interfaces. -## Resources & Contributing Back - -Want to try it out? Check out [our demo](https://react-grab.com). - -Looking to contribute back? Check out the [Contributing Guide](https://github.com/aidenybai/react-grab/blob/main/CONTRIBUTING.md). - -Want to talk to the community? Hop in our [Discord](https://discord.com/invite/G7zxfUzkm7) and share your ideas and what you've built with React Grab. - -Find a bug? Head over to our [issue tracker](https://github.com/aidenybai/react-grab/issues) and we'll do our best to help. We love pull requests, too! - -We expect all contributors to abide by the terms of our [Code of Conduct](https://github.com/aidenybai/react-grab/blob/main/.github/CODE_OF_CONDUCT.md). +## Links -[**→ Start contributing on GitHub**](https://github.com/aidenybai/react-grab/blob/main/CONTRIBUTING.md) +- [Demo](https://react-grab.com) +- [Contributing Guide](https://github.com/aidenybai/react-grab/blob/main/CONTRIBUTING.md) +- [Discord](https://discord.com/invite/G7zxfUzkm7) +- [Issue tracker](https://github.com/aidenybai/react-grab/issues) +- [Code of Conduct](https://github.com/aidenybai/react-grab/blob/main/.github/CODE_OF_CONDUCT.md) ### License diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 14a8ac337..8585833eb 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -208,4 +208,67 @@ test.describe("Element Context Fallback", () => { expect(clipboard.length).toBeLessThanOrEqual(510); }); }); + + test.describe("Source Snippet & Component Instance", () => { + test("surfaces the literal JSX call site so the agent sees the props the user wrote", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + + const todoItem = "[data-testid='todo-list'] ul li:first-child span"; + await reactGrab.hoverElement(todoItem); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement(todoItem); + + const clipboard = await reactGrab.getClipboardContent(); + // TodoItem is rendered with `` + // in App.tsx. Either the source-snippet block or — when the source + // map fetch fails — the JSX-call fallback on the stack line must + // surface the call signature so the agent knows it's working with + // a TodoItem component, not a bare `
  • `. + expect(clipboard).toContain("TodoItem"); + expect(clipboard).toMatch(/ { + await reactGrab.activate(); + + const todoItem = "[data-testid='todo-list'] ul li:first-child span"; + await reactGrab.hoverElement(todoItem); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement(todoItem); + + const clipboard = await reactGrab.getClipboardContent(); + // When a trustworthy source snippet is fetched, the literal JSX + // already lives in the snippet block — re-emitting `in ` on the stack line below is redundant noise. The stack + // line should fall back to the bare component name. + const hasSnippetBlock = /^\/\/ .+\.tsx?:\d+/m.test(clipboard); + if (hasSnippetBlock) { + expect(clipboard).toMatch(/in TodoItem \(at /); + expect(clipboard).not.toMatch(/in { + await reactGrab.activate(); + + const icon = "[data-testid='library-icon-host'] svg"; + await reactGrab.hoverElement(icon); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement(icon); + + const clipboard = await reactGrab.getClipboardContent(); + // Library frames stay in the bare `in Square (lucide-react)` shape + // even if the closest composite fiber is the same library component. + // Painting props onto a library frame would leak internal lucide-react + // implementation details into the agent context. + expect(clipboard).toMatch(/in Square \(lucide-react\)/); + expect(clipboard).not.toMatch(/in { return cachedIsNextProject; }; -const isInternalComponentName = (name: string): boolean => { - if (NEXT_INTERNAL_COMPONENT_NAMES.has(name)) return true; - if (REACT_INTERNAL_COMPONENT_NAMES.has(name)) return true; - for (const prefix of NON_COMPONENT_PREFIXES) { - if (name.startsWith(prefix)) return true; - } - return false; -}; - -const isUsefulComponentName = (name: string): boolean => { - if (!name) return false; - if (isInternalComponentName(name)) return false; - if (name === "SlotClone" || name === "Slot") return false; - return true; -}; - const isSourceComponentName = (name: string): boolean => { if (name.length <= 1) return false; if (isInternalComponentName(name)) return false; @@ -387,6 +329,23 @@ interface StackContextOptions { maxLines?: number; } +interface ElementContextPartsInternalOptions extends StackContextOptions { + includeSourceSnippet?: boolean; +} + +export interface SourceSnippetInfo { + filePath: string; + snippet: SourceSnippet; + block: string; + key: string; +} + +export interface ElementContextParts { + htmlPreview: string; + sourceSnippet: SourceSnippetInfo | null; + stackLines: string[]; +} + const hasFormattableFrames = (stack: StackFrame[] | null): boolean => { if (!stack) return false; return stack.some((frame) => { @@ -422,23 +381,44 @@ const getComponentNamesFromFiber = (element: Element, maxCount: number): string[ return componentNames; }; -const formatResolvedSourceLine = ( +const formatResolvedSourceLocation = ( frame: StackFrame, filePath: string, - componentName: string | null, isNextProject: boolean, ): string => { // HACK: bundlers like Vite produce unreliable line/column numbers from // owner stacks, so we only include them for Next.js where the dev server // symbolicates frames via source maps. - const location = - isNextProject && frame.lineNumber - ? `${normalizeFilePath(filePath)}:${frame.lineNumber}${frame.columnNumber ? `:${frame.columnNumber}` : ""}` - : normalizeFilePath(filePath); - return componentName ? `\n in ${componentName} (at ${location})` : `\n in ${location}`; + if (isNextProject && frame.lineNumber) { + const column = frame.columnNumber ? `:${frame.columnNumber}` : ""; + return `${normalizeFilePath(filePath)}:${frame.lineNumber}${column}`; + } + return normalizeFilePath(filePath); +}; + +const formatResolvedStackLine = ( + frame: StackFrame, + filePath: string, + componentName: string | null, + componentInstanceText: string | null, + isNextProject: boolean, +): string => { + const location = formatResolvedSourceLocation(frame, filePath, isNextProject); + if (componentInstanceText) { + return `in ${componentInstanceText} (at ${location})`; + } + return componentName ? `in ${componentName} (at ${location})` : `in ${location}`; }; -const formatStackContext = (stack: StackFrame[], options: StackContextOptions = {}): string => { +interface StackContextInternalOptions extends StackContextOptions { + innermostComponentInstanceText?: string | null; + innermostComponentName?: string | null; +} + +const formatStackLines = ( + stack: StackFrame[], + options: StackContextInternalOptions = {}, +): string[] => { const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options; const isNextProject = checkIsNextProject(); const lines: string[] = []; @@ -446,6 +426,7 @@ const formatStackContext = (stack: StackFrame[], options: StackContextOptions = // (a deeply nested Radix/MUI tree) collapse to one line and don't evict // the user's own component frames from the tight maxLines budget. let previousLibraryPackage: string | null = null; + let didAttachComponentInstance = false; const emit = (line: string, libraryPackage: string | null) => { lines.push(line); @@ -464,7 +445,7 @@ const formatStackContext = (stack: StackFrame[], options: StackContextOptions = if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; - emit(`\n in ${componentName ?? ""} (${tag})`, libraryPackage); + emit(`in ${componentName ?? ""} (${tag})`, libraryPackage); continue; } @@ -474,52 +455,139 @@ const formatStackContext = (stack: StackFrame[], options: StackContextOptions = // when we can recover it from the file path. if (!resolvedSource && componentName) { emit( - libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`, + libraryPackage ? `in ${componentName} (${libraryPackage})` : `in ${componentName}`, libraryPackage, ); continue; } if (resolvedSource) { - emit(formatResolvedSourceLine(frame, resolvedSource, componentName, isNextProject), null); + // Only attach props when the resolved frame name matches the fiber walk, + // otherwise we'd paint the wrong component's props onto the wrong line. + const shouldAttachInstance = + !didAttachComponentInstance && + Boolean(options.innermostComponentInstanceText) && + componentName !== null && + componentName === options.innermostComponentName; + const componentInstanceText = shouldAttachInstance + ? (options.innermostComponentInstanceText ?? null) + : null; + if (shouldAttachInstance) didAttachComponentInstance = true; + emit( + formatResolvedStackLine( + frame, + resolvedSource, + componentName, + componentInstanceText, + isNextProject, + ), + null, + ); } } - return lines.join(""); + return lines; }; -export const getStackContext = async ( +const buildSourceSnippetInfo = async ( + stack: StackFrame[], + componentName: string | null, +): Promise => { + const frame = stack.find((candidate) => candidate.fileName && isSourceFile(candidate.fileName)); + if (!frame?.fileName) return null; + + const snippet = await getSourceSnippetForFrame(frame, { componentName }); + if (!snippet) return null; + + const filePath = normalizeFilePath(frame.fileName); + return { + filePath, + snippet, + block: formatSourceSnippetBlock(snippet, filePath), + key: `${filePath}:${snippet.highlightLine}`, + }; +}; + +const buildContextParts = async ( element: Element, - options: StackContextOptions = {}, -): Promise => { + options: ElementContextPartsInternalOptions, +): Promise => { + const resolvedElement = findNearestFiberElement(element); + const htmlPreview = getHTMLPreview(resolvedElement); const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; - const stack = await getStack(element); + const stack = await getStack(resolvedElement); if (stack && hasFormattableFrames(stack)) { - return formatStackContext(stack, options); + const componentInfo = getFiberComponentInfo(resolvedElement); + const sourceSnippet = options.includeSourceSnippet + ? await buildSourceSnippetInfo(stack, componentInfo?.name ?? null) + : null; + // When a trustworthy snippet is present its literal JSX supersedes the + // props line, so we drop the props to avoid near-duplicate output. + const hasTrustworthySnippet = Boolean(sourceSnippet) && !sourceSnippet?.snippet.isApproximate; + const componentInstanceText = + hasTrustworthySnippet || !componentInfo + ? null + : formatComponentInstance({ name: componentInfo.name, props: componentInfo.props }); + const stackLines = formatStackLines(stack, { + maxLines, + innermostComponentInstanceText: componentInstanceText, + innermostComponentName: componentInfo?.name ?? null, + }); + return { htmlPreview, sourceSnippet, stackLines }; } - const componentNames = getComponentNamesFromFiber(element, maxLines); + const componentNames = getComponentNamesFromFiber(resolvedElement, maxLines); if (componentNames.length > 0) { - return componentNames.map((name) => `\n in ${name}`).join(""); + // Without a formattable owner stack, the source snippet is unreachable — + // surface the fiber's memoized props on the innermost component name so + // the agent still gets the call shape. + const componentInfo = getFiberComponentInfo(resolvedElement); + const componentInstanceText = + componentInfo && componentInfo.name === componentNames[0] + ? formatComponentInstance({ name: componentInfo.name, props: componentInfo.props }) + : null; + const stackLines = componentNames.map((name, nameIndex) => + nameIndex === 0 && componentInstanceText ? `in ${componentInstanceText}` : `in ${name}`, + ); + return { htmlPreview, sourceSnippet: null, stackLines }; } - return ""; + return { + htmlPreview: getFallbackContext(resolvedElement), + sourceSnippet: null, + stackLines: [], + }; }; -export const getElementContext = async ( +export const getStackContext = async ( element: Element, options: StackContextOptions = {}, ): Promise => { - const resolvedElement = findNearestFiberElement(element); - const html = getHTMLPreview(resolvedElement); - const stackContext = await getStackContext(resolvedElement, options); + const parts = await buildContextParts(element, { ...options, includeSourceSnippet: false }); + if (parts.stackLines.length === 0) return ""; + return parts.stackLines.map((line) => `\n ${line}`).join(""); +}; - if (stackContext) { - return `${html}${stackContext}`; - } +export const getElementContextParts = ( + element: Element, + options: StackContextOptions = {}, +): Promise => + buildContextParts(element, { ...options, includeSourceSnippet: true }); + +export const formatElementContextParts = (parts: ElementContextParts): string => { + const stackText = parts.stackLines.map((line) => `\n ${line}`).join(""); + if (!parts.sourceSnippet) return `${parts.htmlPreview}${stackText}`; + const stackSection = stackText ? `\n${stackText}` : ""; + return `${parts.htmlPreview}\n\n${parts.sourceSnippet.block}${stackSection}`; +}; - return getFallbackContext(resolvedElement); +export const getElementContext = async ( + element: Element, + options: StackContextOptions = {}, +): Promise => { + const parts = await getElementContextParts(element, options); + return formatElementContextParts(parts); }; const getFallbackContext = (element: Element): string => { diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index da2761ca5..8d613a719 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,6 +1,7 @@ +import { formatElementContextParts } from "./context.js"; import { copyContent, type ReactGrabEntry } from "../utils/copy-content.js"; -import { generateSnippet } from "../utils/generate-snippet.js"; -import { joinSnippets } from "../utils/join-snippets.js"; +import { generateSnippetParts } from "../utils/generate-snippet.js"; +import { joinSnippetEntries, type JoinSnippetEntry } from "../utils/join-snippets.js"; import { normalizeError } from "../utils/normalize-error.js"; interface CopyOptions { @@ -36,22 +37,36 @@ export const tryCopyWithFallback = async ( if (options.getContent) { generatedContent = await options.getContent(elements); } else { - const rawSnippets = await generateSnippet(elements, { + const partsList = await generateSnippetParts(elements, { maxLines: options.maxContextLines, }); + const originalSnippets = partsList.map(formatElementContextParts); const transformedSnippets = await Promise.all( - rawSnippets.map((snippet, index) => + originalSnippets.map((snippet, index) => snippet.trim() ? hooks.transformSnippet(snippet, elements[index]) : Promise.resolve(""), ), ); - const snippetElementPairs = transformedSnippets - .map((snippet, index) => ({ snippet, element: elements[index] })) - .filter(({ snippet }) => snippet.trim()); - generatedContent = joinSnippets(snippetElementPairs.map(({ snippet }) => snippet)); - entries = snippetElementPairs.map(({ snippet, element }) => ({ - tagName: element.localName, - content: snippet, + // Plugin transforms can mutate snippet text, breaking the structured + // `parts` invariant the collapse algorithm relies on. + let allowCollapse = true; + const joinEntries: JoinSnippetEntry[] = []; + const trackedElements: Element[] = []; + for (let entryIndex = 0; entryIndex < transformedSnippets.length; entryIndex++) { + const transformed = transformedSnippets[entryIndex]; + if (!transformed.trim()) continue; + if (transformed !== originalSnippets[entryIndex]) allowCollapse = false; + joinEntries.push({ + snippet: transformed, + parts: partsList[entryIndex], + }); + trackedElements.push(elements[entryIndex]); + } + + generatedContent = joinSnippetEntries(joinEntries, { allowCollapse }); + entries = joinEntries.map((entry, entryIndex) => ({ + tagName: trackedElements[entryIndex].localName, + content: entry.snippet, commentText: extraPrompt, })); } diff --git a/packages/react-grab/src/utils/escape-regexp.ts b/packages/react-grab/src/utils/escape-regexp.ts new file mode 100644 index 000000000..4fbaa8c3a --- /dev/null +++ b/packages/react-grab/src/utils/escape-regexp.ts @@ -0,0 +1,4 @@ +const REGEXP_SPECIAL_CHARS_PATTERN = /[.*+?^${}()|[\]\\]/g; + +export const escapeRegExp = (input: string): string => + input.replace(REGEXP_SPECIAL_CHARS_PATTERN, "\\$&"); diff --git a/packages/react-grab/src/utils/find-longest-common-suffix.ts b/packages/react-grab/src/utils/find-longest-common-suffix.ts new file mode 100644 index 000000000..c0831b9c6 --- /dev/null +++ b/packages/react-grab/src/utils/find-longest-common-suffix.ts @@ -0,0 +1,13 @@ +export const findLongestCommonSuffix = (lists: T[][]): T[] => { + if (lists.length === 0) return []; + const minLength = Math.min(...lists.map((list) => list.length)); + let suffixLength = 0; + for (let suffixIndex = 1; suffixIndex <= minLength; suffixIndex++) { + const candidate = lists[0][lists[0].length - suffixIndex]; + const isShared = lists.every((list) => list[list.length - suffixIndex] === candidate); + if (!isShared) break; + suffixLength = suffixIndex; + } + if (suffixLength === 0) return []; + return lists[0].slice(lists[0].length - suffixLength); +}; diff --git a/packages/react-grab/src/utils/format-component-instance.ts b/packages/react-grab/src/utils/format-component-instance.ts new file mode 100644 index 000000000..2a963e228 --- /dev/null +++ b/packages/react-grab/src/utils/format-component-instance.ts @@ -0,0 +1,81 @@ +import { + COMPONENT_INSTANCE_MAX_PROPS, + COMPONENT_INSTANCE_MAX_VALUE_LENGTH_CHARS, +} from "../constants.js"; +import { truncateString } from "./truncate-string.js"; + +interface ComponentInstance { + name: string; + props: Record | null; +} + +const SKIP_PROP_NAMES = new Set(["children", "key", "ref", "dangerouslySetInnerHTML"]); + +const formatPropValue = (value: unknown): string | null => { + if (value === null) return "{null}"; + if (value === undefined) return null; + + if (typeof value === "string") { + return `"${truncateString(value, COMPONENT_INSTANCE_MAX_VALUE_LENGTH_CHARS)}"`; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return `{${String(value)}}`; + } + if (typeof value === "function") { + const functionName = typeof value.name === "string" ? value.name : ""; + return functionName ? `{[Function: ${functionName}]}` : "{[Function]}"; + } + if (typeof value === "symbol") { + return `{${value.toString()}}`; + } + if (Array.isArray(value)) { + return value.length === 0 ? "{[]}" : `{[${value.length} items]}`; + } + if (typeof value === "object") { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? "{Date(Invalid)}" : `{Date(${value.toISOString()})}`; + } + if (value instanceof RegExp) return `{${value.toString()}}`; + if (typeof Element !== "undefined" && value instanceof Element) { + return `{<${value.localName} ...>}`; + } + return "{{...}}"; + } + return null; +}; + +const isRenderablePropName = (name: string): boolean => { + if (SKIP_PROP_NAMES.has(name)) return false; + if (name.startsWith("__")) return false; + if (name.startsWith("$$")) return false; + return true; +}; + +export const formatComponentInstance = (instance: ComponentInstance): string => { + const { name, props } = instance; + if (!props) return `<${name} />`; + + const renderedAttrs: string[] = []; + let truncatedCount = 0; + + for (const propName of Object.keys(props)) { + if (!isRenderablePropName(propName)) continue; + const formatted = formatPropValue(props[propName]); + if (formatted === null) continue; + if (renderedAttrs.length >= COMPONENT_INSTANCE_MAX_PROPS) { + truncatedCount++; + continue; + } + if (formatted === "{true}") { + renderedAttrs.push(propName); + continue; + } + renderedAttrs.push(`${propName}=${formatted}`); + } + + if (renderedAttrs.length === 0 && truncatedCount === 0) return `<${name} />`; + + const attrText = renderedAttrs.join(" "); + const ellipsis = truncatedCount > 0 ? ` /* +${truncatedCount} more */` : ""; + return `<${name} ${attrText}${ellipsis} />`; +}; diff --git a/packages/react-grab/src/utils/format-source-snippet-block.ts b/packages/react-grab/src/utils/format-source-snippet-block.ts new file mode 100644 index 000000000..0497a7d88 --- /dev/null +++ b/packages/react-grab/src/utils/format-source-snippet-block.ts @@ -0,0 +1,15 @@ +import type { SourceSnippet } from "./get-source-snippet.js"; + +export const formatSourceSnippetBlock = (snippet: SourceSnippet, filePath: string): string => { + const headerSuffix = snippet.isApproximate ? " (approximate)" : ""; + const header = `// ${filePath}:${snippet.highlightLine}${headerSuffix}`; + + const lineNumberWidth = String(snippet.endLine).length; + const formattedLines = snippet.lines.map((line, lineIndex) => { + const currentLineNumber = snippet.startLine + lineIndex; + const marker = currentLineNumber === snippet.highlightLine ? "> " : " "; + return `${marker}${String(currentLineNumber).padStart(lineNumberWidth, " ")}| ${line}`; + }); + + return `${header}\n${formattedLines.join("\n")}`; +}; diff --git a/packages/react-grab/src/utils/generate-snippet.ts b/packages/react-grab/src/utils/generate-snippet.ts index a3f2e9555..c5ad43653 100644 --- a/packages/react-grab/src/utils/generate-snippet.ts +++ b/packages/react-grab/src/utils/generate-snippet.ts @@ -1,20 +1,38 @@ -import { getElementContext } from "../core/context.js"; +import { + formatElementContextParts, + getElementContextParts, + type ElementContextParts, +} from "../core/context.js"; +import { logRecoverableError } from "./log-recoverable-error.js"; interface GenerateSnippetOptions { maxLines?: number; } -export const generateSnippet = async ( +const buildEmptyParts = (): ElementContextParts => ({ + htmlPreview: "", + sourceSnippet: null, + stackLines: [], +}); + +export const generateSnippetParts = async ( elements: Element[], options: GenerateSnippetOptions = {}, -): Promise => { - const elementSnippetResults = await Promise.allSettled( - elements.map((element) => getElementContext(element, options)), - ); - - const elementSnippets = elementSnippetResults.map((result) => - result.status === "fulfilled" ? result.value : "", +): Promise => { + const results = await Promise.allSettled( + elements.map((element) => getElementContextParts(element, options)), ); + return results.map((result) => { + if (result.status === "fulfilled") return result.value; + logRecoverableError("generateSnippetParts: failed to build element context", result.reason); + return buildEmptyParts(); + }); +}; - return elementSnippets; +export const generateSnippet = async ( + elements: Element[], + options: GenerateSnippetOptions = {}, +): Promise => { + const partsList = await generateSnippetParts(elements, options); + return partsList.map(formatElementContextParts); }; diff --git a/packages/react-grab/src/utils/get-fiber-component-info.ts b/packages/react-grab/src/utils/get-fiber-component-info.ts new file mode 100644 index 000000000..d63bdf533 --- /dev/null +++ b/packages/react-grab/src/utils/get-fiber-component-info.ts @@ -0,0 +1,36 @@ +import { + getDisplayName, + getFiberFromHostInstance, + isCompositeFiber, + isInstrumentationActive, +} from "bippy"; +import { isUsefulComponentName } from "./is-useful-component-name.js"; + +export interface FiberComponentInfo { + name: string; + props: Record | null; +} + +const isPropsRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +export const getFiberComponentInfo = (element: Element): FiberComponentInfo | null => { + if (!isInstrumentationActive()) return null; + const hostFiber = getFiberFromHostInstance(element); + if (!hostFiber) return null; + + let currentFiber = hostFiber.return; + while (currentFiber) { + if (isCompositeFiber(currentFiber)) { + const name = getDisplayName(currentFiber.type); + if (name && isUsefulComponentName(name)) { + const memoizedProps: unknown = currentFiber.memoizedProps; + const props = isPropsRecord(memoizedProps) ? memoizedProps : null; + return { name, props }; + } + } + currentFiber = currentFiber.return; + } + + return null; +}; diff --git a/packages/react-grab/src/utils/get-source-snippet.ts b/packages/react-grab/src/utils/get-source-snippet.ts new file mode 100644 index 000000000..e7e1b5b6d --- /dev/null +++ b/packages/react-grab/src/utils/get-source-snippet.ts @@ -0,0 +1,206 @@ +import { isSourceFile, sourceMapCache, type SourceMap, type StackFrame } from "bippy/source"; +import { + SOURCE_SNIPPET_FETCH_TIMEOUT_MS, + SOURCE_SNIPPET_LINES_AFTER, + SOURCE_SNIPPET_LINES_BEFORE, + SOURCE_SNIPPET_MAX_LINE_LENGTH_CHARS, +} from "../constants.js"; +import { escapeRegExp } from "./escape-regexp.js"; +import { truncateString } from "./truncate-string.js"; + +export interface SourceSnippet { + startLine: number; + endLine: number; + highlightLine: number; + lines: string[]; + isApproximate: boolean; +} + +const sourceContentByFile = new Map>(); + +// Vite serves transformed code at the same URL as the original (`/src/Foo.tsx` +// returns `_jsxDEV(...)` instead of JSX). Reject those fingerprints so we +// don't show the agent rewritten output that has lost the call site. +const TRANSFORMED_OUTPUT_PATTERN = + /\b_jsxDEV\(|\b_jsx\(|\bjsxRuntime\b|\$RefreshSig\$|var _jsxFileName\s*=/; +const TRANSFORMED_DETECTION_HEAD_CHARS = 2_000; + +export const looksTransformed = (content: string): boolean => + TRANSFORMED_OUTPUT_PATTERN.test(content.slice(0, TRANSFORMED_DETECTION_HEAD_CHARS)); + +// Suffix matching covers bundlers that prefix `sources` entries +// (`webpack://`, `vite://`, etc). Require ≥2 path segments so a basename like +// `index.tsx` can't false-match an unrelated module in another cached map. +const MIN_SUFFIX_SEGMENTS = 2; + +const findContentBySuffixMatch = (sourceMap: SourceMap, fileName: string): string | null => { + if (!sourceMap.sources || !sourceMap.sourcesContent) return null; + const lookupSuffix = fileName.startsWith("/") ? fileName : `/${fileName}`; + if (lookupSuffix.split("/").length - 1 < MIN_SUFFIX_SEGMENTS) return null; + for (let sourceIndex = 0; sourceIndex < sourceMap.sources.length; sourceIndex++) { + const sourceEntry = sourceMap.sources[sourceIndex]; + if (!sourceEntry) continue; + if (sourceEntry.endsWith(lookupSuffix)) { + const content = sourceMap.sourcesContent[sourceIndex]; + if (content) return content; + } + } + return null; +}; + +const findContentInSourceMap = ( + sourceMap: SourceMap | undefined, + fileName: string, +): string | null => { + if (!sourceMap) return null; + if (sourceMap.sources && sourceMap.sourcesContent) { + const exactIndex = sourceMap.sources.indexOf(fileName); + if (exactIndex !== -1) { + const content = sourceMap.sourcesContent[exactIndex]; + if (content) return content; + } + const suffixMatch = findContentBySuffixMatch(sourceMap, fileName); + if (suffixMatch) return suffixMatch; + } + if (sourceMap.sections) { + for (const section of sourceMap.sections) { + const content = findContentInSourceMap(section.map, fileName); + if (content) return content; + } + } + return null; +}; + +const findCachedSourceContent = (fileName: string): string | null => { + for (const cached of sourceMapCache.values()) { + if (!cached) continue; + const sourceMap = cached instanceof WeakRef ? cached.deref() : cached; + if (!sourceMap) continue; + const content = findContentInSourceMap(sourceMap, fileName); + if (content) return content; + } + return null; +}; + +// Same-origin only: prevents a doctored `_debugSource.fileName` from causing +// the dev's browser to fetch attacker URLs. Also rejects `//host/...` +// protocol-relative URLs that string-prefix checks would let through. +const isFetchableUrl = (fileName: string): boolean => { + if (typeof location === "undefined") return false; + try { + return new URL(fileName, location.origin).origin === location.origin; + } catch { + return false; + } +}; + +const fetchOriginalSource = async (fileName: string): Promise => { + if (!isFetchableUrl(fileName)) return null; + + const url = new URL(fileName, location.origin).toString(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SOURCE_SNIPPET_FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { + credentials: "same-origin", + signal: controller.signal, + }); + if (!response.ok) return null; + const body = await response.text(); + if (looksTransformed(body)) return null; + return body; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +}; + +// Cache in-flight promises to dedupe concurrent calls, but evict null results +// so a transient HMR-rebuild failure doesn't permanently block snippets for +// that file. +const getSourceContent = (fileName: string): Promise => { + const cached = sourceContentByFile.get(fileName); + if (cached) return cached; + + const fromMap = findCachedSourceContent(fileName); + if (fromMap) { + const resolved = Promise.resolve(fromMap); + sourceContentByFile.set(fileName, resolved); + return resolved; + } + + const promise = fetchOriginalSource(fileName); + sourceContentByFile.set(fileName, promise); + promise + .then((value) => { + if (value === null && sourceContentByFile.get(fileName) === promise) { + sourceContentByFile.delete(fileName); + } + }) + .catch(() => { + if (sourceContentByFile.get(fileName) === promise) { + sourceContentByFile.delete(fileName); + } + }); + return promise; +}; + +// Match only proper component tags (`` or +// `function` trigger on every utility file and silently mark wrong-target +// resolutions as trustworthy. +const JSX_TAG_PATTERN = /<[A-Z][A-Za-z0-9]*[\s/>]/; + +const isSnippetTrustworthy = (windowLines: string[], componentName: string | null): boolean => { + const joined = windowLines.join("\n"); + if (!joined.trim()) return false; + if (JSX_TAG_PATTERN.test(joined)) return true; + if (componentName) { + const componentNamePattern = new RegExp(`\\b${escapeRegExp(componentName)}\\b`); + if (componentNamePattern.test(joined)) return true; + } + return false; +}; + +const sliceSnippetWindow = ( + sourceLines: string[], + resolvedLineNumber: number, +): { startLine: number; endLine: number; lines: string[] } | null => { + if (resolvedLineNumber < 1 || resolvedLineNumber > sourceLines.length) return null; + + const startLine = Math.max(1, resolvedLineNumber - SOURCE_SNIPPET_LINES_BEFORE); + const endLine = Math.min(sourceLines.length, resolvedLineNumber + SOURCE_SNIPPET_LINES_AFTER); + const lines = sourceLines + .slice(startLine - 1, endLine) + .map((line) => truncateString(line, SOURCE_SNIPPET_MAX_LINE_LENGTH_CHARS)); + + return { startLine, endLine, lines }; +}; + +interface GetSourceSnippetOptions { + componentName?: string | null; +} + +export const getSourceSnippetForFrame = async ( + frame: StackFrame, + options: GetSourceSnippetOptions = {}, +): Promise => { + if (!frame.fileName || !isSourceFile(frame.fileName)) return null; + if (typeof frame.lineNumber !== "number" || frame.lineNumber < 1) return null; + + const sourceContent = await getSourceContent(frame.fileName); + if (!sourceContent) return null; + + const sourceLines = sourceContent.split("\n"); + const sliced = sliceSnippetWindow(sourceLines, frame.lineNumber); + if (!sliced) return null; + + return { + startLine: sliced.startLine, + endLine: sliced.endLine, + highlightLine: frame.lineNumber, + lines: sliced.lines, + isApproximate: !isSnippetTrustworthy(sliced.lines, options.componentName ?? null), + }; +}; diff --git a/packages/react-grab/src/utils/is-useful-component-name.ts b/packages/react-grab/src/utils/is-useful-component-name.ts new file mode 100644 index 000000000..0251a1557 --- /dev/null +++ b/packages/react-grab/src/utils/is-useful-component-name.ts @@ -0,0 +1,62 @@ +const NON_COMPONENT_PREFIXES = new Set([ + "_", + "$", + "motion.", + "styled.", + "chakra.", + "ark.", + "Primitive.", + "Slot.", +]); + +const NEXT_INTERNAL_COMPONENT_NAMES = new Set([ + "InnerLayoutRouter", + "RedirectErrorBoundary", + "RedirectBoundary", + "HTTPAccessFallbackErrorBoundary", + "HTTPAccessFallbackBoundary", + "LoadingBoundary", + "ErrorBoundary", + "InnerScrollAndFocusHandler", + "ScrollAndFocusHandler", + "RenderFromTemplateContext", + "OuterLayoutRouter", + "body", + "html", + "DevRootHTTPAccessFallbackBoundary", + "AppDevOverlayErrorBoundary", + "AppDevOverlay", + "HotReload", + "Router", + "ErrorBoundaryHandler", + "AppRouter", + "ServerRoot", + "SegmentStateProvider", + "RootErrorBoundary", + "LoadableComponent", + "MotionDOMComponent", +]); + +const REACT_INTERNAL_COMPONENT_NAMES = new Set([ + "Suspense", + "Fragment", + "StrictMode", + "Profiler", + "SuspenseList", +]); + +export const isInternalComponentName = (name: string): boolean => { + if (NEXT_INTERNAL_COMPONENT_NAMES.has(name)) return true; + if (REACT_INTERNAL_COMPONENT_NAMES.has(name)) return true; + for (const prefix of NON_COMPONENT_PREFIXES) { + if (name.startsWith(prefix)) return true; + } + return false; +}; + +export const isUsefulComponentName = (name: string): boolean => { + if (!name) return false; + if (isInternalComponentName(name)) return false; + if (name === "SlotClone" || name === "Slot") return false; + return true; +}; diff --git a/packages/react-grab/src/utils/join-snippets.ts b/packages/react-grab/src/utils/join-snippets.ts index 760f5b1bc..87cb117ba 100644 --- a/packages/react-grab/src/utils/join-snippets.ts +++ b/packages/react-grab/src/utils/join-snippets.ts @@ -1,5 +1,65 @@ +import type { ElementContextParts } from "../core/context.js"; +import { findLongestCommonSuffix } from "./find-longest-common-suffix.js"; + +export interface JoinSnippetEntry { + snippet: string; + parts: ElementContextParts; +} + +interface JoinSnippetEntriesOptions { + allowCollapse: boolean; +} + +const indentStackLines = (stackLines: string[]): string => + stackLines.map((line) => ` ${line}`).join("\n"); + +const formatDivergingStackLines = (stackLines: string[]): string => + stackLines.length > 0 ? `\n${indentStackLines(stackLines)}` : ""; + +const renderLegacyMultiEntry = (snippets: string[]): string => + snippets.map((snippet, index) => `[${index + 1}]\n${snippet}`).join("\n\n"); + export const joinSnippets = (snippets: string[]): string => { if (snippets.length <= 1) return snippets[0] ?? ""; + return renderLegacyMultiEntry(snippets); +}; + +export const joinSnippetEntries = ( + entries: JoinSnippetEntry[], + options: JoinSnippetEntriesOptions, +): string => { + if (entries.length === 0) return ""; + if (entries.length === 1) return entries[0].snippet; + + if (!options.allowCollapse) { + return renderLegacyMultiEntry(entries.map((entry) => entry.snippet)); + } + + const stackLists = entries.map((entry) => entry.parts.stackLines); + const sharedStack = findLongestCommonSuffix(stackLists); + if (sharedStack.length === 0) { + return renderLegacyMultiEntry(entries.map((entry) => entry.snippet)); + } + + const firstSnippetKey = entries[0].parts.sourceSnippet?.key; + const haveSharedSnippet = + Boolean(firstSnippetKey) && + entries.every((entry) => entry.parts.sourceSnippet?.key === firstSnippetKey); + const sharedSnippetBlock = haveSharedSnippet ? entries[0].parts.sourceSnippet?.block : null; + + let anyDivergence = false; + const renderedEntries = entries.map((entry, entryIndex) => { + const divergingLines = entry.parts.stackLines.slice( + 0, + entry.parts.stackLines.length - sharedStack.length, + ); + if (divergingLines.length > 0) anyDivergence = true; + return `[${entryIndex + 1}] ${entry.parts.htmlPreview}${formatDivergingStackLines(divergingLines)}`; + }); - return snippets.map((snippet, index) => `[${index + 1}]\n${snippet}`).join("\n\n"); + const entrySeparator = anyDivergence ? "\n\n" : "\n"; + const sections: string[] = [renderedEntries.join(entrySeparator)]; + if (sharedSnippetBlock) sections.push(sharedSnippetBlock); + sections.push(indentStackLines(sharedStack)); + return sections.join("\n\n"); }; diff --git a/packages/react-grab/test/escape-regexp.test.ts b/packages/react-grab/test/escape-regexp.test.ts new file mode 100644 index 000000000..90eb961d6 --- /dev/null +++ b/packages/react-grab/test/escape-regexp.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; +import { escapeRegExp } from "../src/utils/escape-regexp.js"; + +describe("escapeRegExp", () => { + it("escapes regex metacharacters", () => { + expect(escapeRegExp(".*+?^${}()|[]\\")).toBe("\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\"); + }); + + it("leaves plain alphanumerics untouched", () => { + expect(escapeRegExp("MyComponent_42")).toBe("MyComponent_42"); + }); + + it("produces a regex source that matches the original string literally", () => { + const input = "a.b+c[d]"; + const pattern = new RegExp(`^${escapeRegExp(input)}$`); + expect(pattern.test(input)).toBe(true); + expect(pattern.test("aXbXcXdX")).toBe(false); + }); +}); diff --git a/packages/react-grab/test/find-longest-common-suffix.test.ts b/packages/react-grab/test/find-longest-common-suffix.test.ts new file mode 100644 index 000000000..a999c7c86 --- /dev/null +++ b/packages/react-grab/test/find-longest-common-suffix.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vite-plus/test"; +import { findLongestCommonSuffix } from "../src/utils/find-longest-common-suffix.js"; + +describe("findLongestCommonSuffix", () => { + it("returns an empty array when no lists are provided", () => { + expect(findLongestCommonSuffix([])).toEqual([]); + }); + + it("returns an empty array when any list is empty", () => { + expect(findLongestCommonSuffix([["a", "b"], []])).toEqual([]); + }); + + it("returns the full list when there is only one list", () => { + expect(findLongestCommonSuffix([["a", "b", "c"]])).toEqual(["a", "b", "c"]); + }); + + it("returns the shared suffix between two lists", () => { + expect( + findLongestCommonSuffix([ + ["x", "common", "tail"], + ["y", "z", "common", "tail"], + ]), + ).toEqual(["common", "tail"]); + }); + + it("returns an empty array when no suffix is shared", () => { + expect( + findLongestCommonSuffix([ + ["a", "b"], + ["c", "d"], + ]), + ).toEqual([]); + }); + + it("compares by strict equality across many lists", () => { + expect( + findLongestCommonSuffix([ + ["1", "shared"], + ["2", "shared"], + ["3", "shared"], + ]), + ).toEqual(["shared"]); + }); + + it("returns an empty array if a single list breaks the suffix", () => { + expect( + findLongestCommonSuffix([ + ["x", "common"], + ["y", "common"], + ["z", "different"], + ]), + ).toEqual([]); + }); +}); diff --git a/packages/react-grab/test/format-component-instance.test.ts b/packages/react-grab/test/format-component-instance.test.ts new file mode 100644 index 000000000..1df531e57 --- /dev/null +++ b/packages/react-grab/test/format-component-instance.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vite-plus/test"; +import { formatComponentInstance } from "../src/utils/format-component-instance.js"; + +describe("formatComponentInstance", () => { + it("renders a self-closing tag for a component with no props", () => { + expect(formatComponentInstance({ name: "Button", props: null })).toBe("