Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ It makes tools like Cursor, Claude Code, Copilot run up to [**3× faster**](http

## Install

Run this command at your project root (where `next.config.ts` or `vite.config.ts` is located):
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
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 12 additions & 2 deletions apps/website/public/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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`):
Expand Down
10 changes: 10 additions & 0 deletions apps/website/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,16 @@ Add this to your `index.html`:
</html>
```

### SvelteKit

Add this to `src/hooks.client.ts`:

```ts
if (import.meta.env.DEV) {
import("react-grab");
}
```

### Webpack

First, install React Grab:
Expand Down
1 change: 1 addition & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,5 @@ npx grab@latest configure
| Next.js (App Router) | `next.config.ts` + `app/` directory |
| Next.js (Pages Router) | `next.config.ts` + `pages/` directory |
| Vite | `vite.config.ts` |
| SvelteKit | `svelte.config.*` |
| Webpack | `webpack.config.*` |
7 changes: 4 additions & 3 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const FRAMEWORK_NAMES: Record<Framework, string> = {
vite: "Vite",
tanstack: "TanStack Start",
webpack: "Webpack",
sveltekit: "SvelteKit",
unknown: "Unknown",
};

Expand All @@ -71,7 +72,6 @@ const PACKAGE_MANAGER_NAMES: Record<PackageManager, string> = {
const UNSUPPORTED_FRAMEWORK_NAMES: Record<NonNullable<UnsupportedFramework>, string> = {
remix: "Remix",
astro: "Astro",
sveltekit: "SvelteKit",
gatsby: "Gatsby",
};

Expand Down Expand Up @@ -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.");
Expand Down
16 changes: 10 additions & 6 deletions packages/cli/src/utils/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,6 +55,10 @@ export const detectFramework = (projectRoot: string): Framework => {
return "tanstack";
}

if (allDependencies["@sveltejs/kit"]) {
return "sveltekit";
}

if (allDependencies["vite"]) {
return "vite";
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -411,10 +419,6 @@ export const detectUnsupportedFramework = (projectRoot: string): UnsupportedFram
return "astro";
}

if (allDependencies["@sveltejs/kit"]) {
return "sveltekit";
}

if (allDependencies["gatsby"]) {
return "gatsby";
}
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/utils/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}`;
Expand Down
73 changes: 71 additions & 2 deletions packages/cli/src/utils/transform.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -625,6 +690,8 @@ const findReactGrabFile = (
return findTanStackRootFile(projectRoot);
case "webpack":
return findEntryFile(projectRoot);
case "sveltekit":
return findSvelteKitHooksClientFile(projectRoot);
default:
return null;
}
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/test/configure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/test/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }));
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading