diff --git a/README.md b/README.md index ab655f1f1..1f4e5d0a2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ React Grab points agents to the actual source behind each selection, so edits ar ## Quick Start -Run this at your project root: +Run this command at your project root (where `next.config.ts`, `vite.config.ts`, or `svelte.config.js` is located): ```bash npx grab@latest init @@ -108,6 +108,16 @@ if (import.meta.env.DEV) { } ``` +#### SvelteKit + +Add this to `src/hooks.client.ts`: + +```ts +if (import.meta.env.DEV) { + import("react-grab"); +} +``` + #### Webpack First, install React Grab: diff --git a/apps/website/public/install.md b/apps/website/public/install.md index c7f94f27c..36ec5e75e 100644 --- a/apps/website/public/install.md +++ b/apps/website/public/install.md @@ -9,7 +9,7 @@ Install React Grab in a React project to enable element grabbing for AI coding a ## DONE WHEN - [ ] React Grab package is installed -- [ ] Framework integration is configured (Next.js, Vite, or Webpack) +- [ ] Framework integration is configured (Next.js, Vite, SvelteKit, or Webpack) - [ ] Running the dev server shows the grab overlay when pressing the activation key ## INSTALLATION @@ -28,7 +28,7 @@ The CLI will auto-detect your framework and configure everything automatically. ``` Options: - -f, --framework Override detected framework [choices: "next", "vite", "webpack"] + -f, --framework Override detected framework [choices: "next", "vite", "sveltekit", "webpack"] -p, --package-manager Override detected package manager [choices: "npm", "yarn", "pnpm", "bun"] -r, --router Next.js router type [choices: "app", "pages"] -k, --key Activation key (e.g., "Meta+K", "Ctrl+Shift+G", "Space") @@ -122,6 +122,16 @@ if (import.meta.env.DEV) { } ``` +#### SvelteKit + +Add to `src/hooks.client.ts`: + +```ts +if (import.meta.env.DEV) { + import("react-grab"); +} +``` + #### Webpack Add to your main entry file (e.g., `src/index.tsx`): diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt index 817165966..d6a7079dd 100644 --- a/apps/website/public/llms.txt +++ b/apps/website/public/llms.txt @@ -167,6 +167,16 @@ Add this to your `index.html`: ``` +### SvelteKit + +Add this to `src/hooks.client.ts`: + +```ts +if (import.meta.env.DEV) { + import("react-grab"); +} +``` + ### Webpack First, install React Grab: diff --git a/packages/cli/README.md b/packages/cli/README.md index 6dd3afd51..75f4531fe 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -69,10 +69,11 @@ npx grab@latest configure ## Supported Frameworks -The CLI currently configures: - -- Next.js App Router -- Next.js Pages Router -- Vite -- TanStack Start -- Webpack +| Framework | Detection | +| ---------------------- | ------------------------------------- | +| Next.js (App Router) | `next.config.ts` + `app/` directory | +| Next.js (Pages Router) | `next.config.ts` + `pages/` directory | +| Vite | `vite.config.ts` | +| TanStack Start | `app.config.ts` | +| SvelteKit | `svelte.config.*` | +| Webpack | `webpack.config.*` | diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 75588a0a2..33df6b1e9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -58,6 +58,7 @@ const FRAMEWORK_NAMES: Record = { vite: "Vite", tanstack: "TanStack Start", webpack: "Webpack", + sveltekit: "SvelteKit", unknown: "Unknown", }; @@ -71,7 +72,6 @@ const PACKAGE_MANAGER_NAMES: Record = { const UNSUPPORTED_FRAMEWORK_NAMES: Record, string> = { remix: "Remix", astro: "Astro", - sveltekit: "SvelteKit", gatsby: "Gatsby", }; @@ -495,12 +495,13 @@ export const init = new Command() process.exit(1); } - const hasLayoutChanges = !result.noChanges && result.originalContent && result.newContent; + const hasLayoutChanges = + !result.noChanges && result.filePath && result.newContent !== undefined; if (hasLayoutChanges) { logger.break(); - printDiff(result.filePath, result.originalContent!, result.newContent!); + printDiff(result.filePath, result.originalContent ?? "", result.newContent!); logger.break(); logger.warn("Auto-detection may not be 100% accurate."); diff --git a/packages/cli/src/utils/detect.ts b/packages/cli/src/utils/detect.ts index 71482b35c..af23f5f84 100644 --- a/packages/cli/src/utils/detect.ts +++ b/packages/cli/src/utils/detect.ts @@ -4,9 +4,9 @@ import { detect } from "@antfu/ni"; import ignore from "ignore"; export type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; -export type Framework = "next" | "vite" | "tanstack" | "webpack" | "unknown"; +export type Framework = "next" | "vite" | "tanstack" | "webpack" | "sveltekit" | "unknown"; export type NextRouterType = "app" | "pages" | "unknown"; -export type UnsupportedFramework = "remix" | "astro" | "sveltekit" | "gatsby" | null; +export type UnsupportedFramework = "remix" | "astro" | "gatsby" | null; interface ProjectInfo { packageManager: PackageManager; @@ -55,6 +55,10 @@ export const detectFramework = (projectRoot: string): Framework => { return "tanstack"; } + if (allDependencies["@sveltejs/kit"]) { + return "sveltekit"; + } + if (allDependencies["vite"]) { return "vite"; } @@ -384,6 +388,10 @@ export const detectReactGrab = (projectRoot: string): boolean => { join(projectRoot, "src", "routes", "__root.jsx"), join(projectRoot, "app", "routes", "__root.tsx"), join(projectRoot, "app", "routes", "__root.jsx"), + join(projectRoot, "src", "hooks.client.ts"), + join(projectRoot, "src", "hooks.client.js"), + join(projectRoot, "src", "app.html"), + join(projectRoot, "src", "routes", "+layout.svelte"), ]; return filesToCheck.some(hasReactGrabInFile); @@ -411,10 +419,6 @@ export const detectUnsupportedFramework = (projectRoot: string): UnsupportedFram return "astro"; } - if (allDependencies["@sveltejs/kit"]) { - return "sveltekit"; - } - if (allDependencies["gatsby"]) { return "gatsby"; } diff --git a/packages/cli/src/utils/templates.ts b/packages/cli/src/utils/templates.ts index b9eb6c0bf..589ccbe85 100644 --- a/packages/cli/src/utils/templates.ts +++ b/packages/cli/src/utils/templates.ts @@ -10,6 +10,10 @@ export const VITE_IMPORT = `if (import.meta.env.DEV) { import("react-grab"); }`; +export const SVELTEKIT_IMPORT = `if (import.meta.env.DEV) { + void import("react-grab"); +}`; + export const WEBPACK_IMPORT = `if (process.env.NODE_ENV === "development") { import("react-grab"); }`; diff --git a/packages/cli/src/utils/transform.ts b/packages/cli/src/utils/transform.ts index da0a3915b..4f4edc228 100644 --- a/packages/cli/src/utils/transform.ts +++ b/packages/cli/src/utils/transform.ts @@ -1,9 +1,10 @@ import { accessSync, constants, existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { Framework, NextRouterType } from "./detect.js"; import { NEXT_APP_ROUTER_SCRIPT, SCRIPT_IMPORT, + SVELTEKIT_IMPORT, TANSTACK_EFFECT, VITE_IMPORT, WEBPACK_IMPORT, @@ -152,6 +153,14 @@ const findTanStackRootFile = (projectRoot: string): string | null => { return null; }; +const findSvelteKitHooksClientFile = (projectRoot: string): string | null => { + const candidates = [ + join(projectRoot, "src", "hooks.client.ts"), + join(projectRoot, "src", "hooks.client.js"), + ]; + return candidates.find(existsSync) ?? null; +}; + const alreadyConfiguredResult = (filePath: string): TransformResult => ({ success: true, filePath, @@ -395,6 +404,55 @@ const transformWebpack = ( }; }; +const transformSvelteKit = ( + projectRoot: string, + reactGrabAlreadyConfigured: boolean, + force: boolean = false, +): TransformResult => { + if (!force) { + const appHtml = join(projectRoot, "src", "app.html"); + if (existsSync(appHtml)) { + const existing = checkExistingInstallation(appHtml, reactGrabAlreadyConfigured); + if (existing) return existing; + } + } + + if (!existsSync(join(projectRoot, "src"))) { + return { + success: false, + filePath: "", + message: "Could not find src/ directory for SvelteKit project", + }; + } + + const existingHooks = findSvelteKitHooksClientFile(projectRoot); + + if (existingHooks) { + if (!force) { + const existing = checkExistingInstallation(existingHooks, reactGrabAlreadyConfigured); + if (existing) return existing; + } + const originalContent = readFileSync(existingHooks, "utf-8"); + const newContent = `${SVELTEKIT_IMPORT}\n\n${originalContent}`; + return { + success: true, + filePath: existingHooks, + message: "Add React Grab", + originalContent, + newContent, + }; + } + + const newFilePath = join(projectRoot, "src", "hooks.client.ts"); + return { + success: true, + filePath: newFilePath, + message: "Create src/hooks.client.ts with React Grab", + originalContent: "", + newContent: `${SVELTEKIT_IMPORT}\n`, + }; +}; + const transformTanStack = ( projectRoot: string, reactGrabAlreadyConfigured: boolean, @@ -507,6 +565,9 @@ export const previewTransform = ( case "webpack": return transformWebpack(projectRoot, reactGrabAlreadyConfigured, force); + case "sveltekit": + return transformSvelteKit(projectRoot, reactGrabAlreadyConfigured, force); + default: return { success: false, @@ -518,7 +579,11 @@ export const previewTransform = ( const canWriteToFile = (filePath: string): boolean => { try { - accessSync(filePath, constants.W_OK); + if (existsSync(filePath)) { + accessSync(filePath, constants.W_OK); + } else { + accessSync(dirname(filePath), constants.W_OK); + } return true; } catch { return false; @@ -625,6 +690,8 @@ const findReactGrabFile = ( return findTanStackRootFile(projectRoot); case "webpack": return findEntryFile(projectRoot); + case "sveltekit": + return findSvelteKitHooksClientFile(projectRoot); default: return null; } @@ -770,6 +837,8 @@ export const previewOptionsTransform = ( return addOptionsToTanStackImport(originalContent, options, filePath); case "webpack": return addOptionsToDynamicImport(originalContent, options, filePath); + case "sveltekit": + return addOptionsToDynamicImport(originalContent, options, filePath); default: return { success: false, diff --git a/packages/cli/test/configure.test.ts b/packages/cli/test/configure.test.ts index e3a698b75..37bbde36c 100644 --- a/packages/cli/test/configure.test.ts +++ b/packages/cli/test/configure.test.ts @@ -470,6 +470,53 @@ import ReactDOM from "react-dom/client";`; }); }); +describe("previewOptionsTransform - SvelteKit", () => { + const hooksWithReactGrab = `if (import.meta.env.DEV) { + void import("react-grab"); +}`; + + it("should add options to SvelteKit client hook import", () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith("hooks.client.ts")); + mockReadFileSync.mockReturnValue(hooksWithReactGrab); + + const options: ReactGrabOptions = { + activationKey: "Ctrl+G", + activationMode: "hold", + keyHoldDuration: 250, + }; + + const result = previewOptionsTransform("/test", "sveltekit", "unknown", options); + + expect(result.success).toBe(true); + expect(result.filePath).toBe("/test/src/hooks.client.ts"); + expect(result.newContent).toContain(".then((m) => m.init("); + expect(result.newContent).toContain('"activationKey":"Ctrl+G"'); + expect(result.newContent).toContain('"activationMode":"hold"'); + expect(result.newContent).toContain('"keyHoldDuration":250'); + }); + + it("should update existing SvelteKit options without duplicating", () => { + const hooksWithOptions = `if (import.meta.env.DEV) { + import("react-grab").then((m) => m.init({"activationKey":"g"})); +}`; + + mockExistsSync.mockImplementation((path) => String(path).endsWith("hooks.client.ts")); + mockReadFileSync.mockReturnValue(hooksWithOptions); + + const options: ReactGrabOptions = { + activationKey: "Meta+K", + }; + + const result = previewOptionsTransform("/test", "sveltekit", "unknown", options); + + expect(result.success).toBe(true); + expect(result.newContent).toContain('"activationKey":"Meta+K"'); + expect(result.newContent).not.toContain('"activationKey":"g"'); + const initCount = (result.newContent!.match(/\.then\(/g) || []).length; + expect(initCount).toBe(1); + }); +}); + describe("previewOptionsTransform - Unknown framework", () => { it("should fail for unknown framework (no file found)", () => { mockExistsSync.mockReturnValue(false); diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 55fb58b60..218ca72b0 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -38,6 +38,15 @@ describe("detectFramework", () => { expect(detectFramework("/test")).toBe("vite"); }); + it("should detect SvelteKit", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ devDependencies: { "@sveltejs/kit": "2.0.0", vite: "6.0.0" } }), + ); + + expect(detectFramework("/test")).toBe("sveltekit"); + }); + it("should detect Webpack", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(JSON.stringify({ devDependencies: { webpack: "5.0.0" } })); @@ -253,13 +262,13 @@ describe("detectUnsupportedFramework", () => { expect(detectUnsupportedFramework("/test")).toBe("astro"); }); - it("should detect SvelteKit", () => { + it("should not report SvelteKit as unsupported", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( JSON.stringify({ devDependencies: { "@sveltejs/kit": "2.0.0" } }), ); - expect(detectUnsupportedFramework("/test")).toBe("sveltekit"); + expect(detectUnsupportedFramework("/test")).toBe(null); }); it("should detect Gatsby", () => { diff --git a/packages/cli/test/transform.test.ts b/packages/cli/test/transform.test.ts index 290cbf0c4..9093b59f8 100644 --- a/packages/cli/test/transform.test.ts +++ b/packages/cli/test/transform.test.ts @@ -107,6 +107,94 @@ ReactDOM.createRoot(document.getElementById("root")!).render( }); }); +describe("previewTransform - SvelteKit", () => { + it("should create hooks.client.ts when no client hook exists", () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith("/src")); + + const result = previewTransform("/test", "sveltekit", "unknown", false); + + expect(result.success).toBe(true); + expect(result.filePath).toBe("/test/src/hooks.client.ts"); + expect(result.originalContent).toBe(""); + expect(result.newContent).toContain('void import("react-grab")'); + expect(result.newContent).toContain("import.meta.env.DEV"); + }); + + it("should add React Grab to an existing TypeScript client hook", () => { + const hooksContent = `export const handleError = ({ error }) => { + console.error(error); +};`; + + mockExistsSync.mockImplementation((path) => { + const pathString = String(path); + return pathString.endsWith("/src") || pathString.endsWith("hooks.client.ts"); + }); + mockReadFileSync.mockReturnValue(hooksContent); + + const result = previewTransform("/test", "sveltekit", "unknown", false); + + expect(result.success).toBe(true); + expect(result.filePath).toBe("/test/src/hooks.client.ts"); + expect(result.newContent).toContain('void import("react-grab")'); + expect(result.newContent).toContain(hooksContent); + }); + + it("should add React Grab to an existing JavaScript client hook", () => { + mockExistsSync.mockImplementation((path) => { + const pathString = String(path); + return pathString.endsWith("/src") || pathString.endsWith("hooks.client.js"); + }); + mockReadFileSync.mockReturnValue("export const reroute = () => undefined;"); + + const result = previewTransform("/test", "sveltekit", "unknown", false); + + expect(result.success).toBe(true); + expect(result.filePath).toBe("/test/src/hooks.client.js"); + expect(result.newContent).toContain('void import("react-grab")'); + }); + + it("should not duplicate if React Grab already exists in hooks.client", () => { + mockExistsSync.mockImplementation((path) => { + const pathString = String(path); + return pathString.endsWith("/src") || pathString.endsWith("hooks.client.ts"); + }); + mockReadFileSync.mockReturnValue(`if (import.meta.env.DEV) { + void import("react-grab"); +}`); + + const result = previewTransform("/test", "sveltekit", "unknown", false); + + expect(result.success).toBe(true); + expect(result.noChanges).toBe(true); + }); + + it("should not duplicate if React Grab already exists in app.html", () => { + mockExistsSync.mockImplementation((path) => { + const pathString = String(path); + return pathString.endsWith("/src") || pathString.endsWith("app.html"); + }); + mockReadFileSync.mockReturnValue(` + + %sveltekit.body% +`); + + const result = previewTransform("/test", "sveltekit", "unknown", false); + + expect(result.success).toBe(true); + expect(result.noChanges).toBe(true); + expect(result.filePath).toBe("/test/src/app.html"); + }); + + it("should fail when src directory is missing", () => { + mockExistsSync.mockReturnValue(false); + + const result = previewTransform("/test", "sveltekit", "unknown", false); + + expect(result.success).toBe(false); + expect(result.message).toContain("Could not find src/ directory"); + }); +}); + describe("previewTransform - Webpack", () => { const entryContent = `import React from "react"; import ReactDOM from "react-dom/client"; diff --git a/packages/grab/README.md b/packages/grab/README.md index 721321437..8d1cabc8d 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -11,7 +11,7 @@ React Grab points agents to the actual source behind each selection, so edits ar ## Quick Start -Run this at your project root: +Run this command at your project root (where `next.config.ts`, `vite.config.ts`, or `svelte.config.js` is located): ```bash npx grab@latest init @@ -108,6 +108,16 @@ if (import.meta.env.DEV) { } ``` +#### SvelteKit + +Add this to `src/hooks.client.ts`: + +```ts +if (import.meta.env.DEV) { + import("grab"); +} +``` + #### Webpack First, install React Grab: diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index ab655f1f1..1f4e5d0a2 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -11,7 +11,7 @@ React Grab points agents to the actual source behind each selection, so edits ar ## Quick Start -Run this at your project root: +Run this command at your project root (where `next.config.ts`, `vite.config.ts`, or `svelte.config.js` is located): ```bash npx grab@latest init @@ -108,6 +108,16 @@ if (import.meta.env.DEV) { } ``` +#### SvelteKit + +Add this to `src/hooks.client.ts`: + +```ts +if (import.meta.env.DEV) { + import("react-grab"); +} +``` + #### Webpack First, install React Grab: