From 5f715dfd5892d042b50b1d339ac43be14b9f1106 Mon Sep 17 00:00:00 2001 From: Arda Soyturk <33841775+ardasoyturk@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:38:12 +0300 Subject: [PATCH 1/5] feat(cli): add Astro framework support - Add Astro detection in detectFramework() and detectReactGrab() - Add transformAstro() for injecting React Grab code into Astro layouts - Add findAstroLayoutFile() and findAllAstroLayoutFile() to locate layout files - Add addAgentToExistingAstro() and removeAgentFromAstro() for agent management - Add addOptionsToAstroScript() for options configuration - Add ASTRO_EFFECT and ASTRO_EFFECT_WITH_AGENT templates - Update FRAMEWORK_NAMES to include Astro - Remove Astro from UNSUPPORTED_FRAMEWORK_NAMES - Update tests for Astro support --- packages/cli/src/commands/init.ts | 7 +- packages/cli/src/utils/detect.ts | 36 +++-- packages/cli/src/utils/templates.ts | 21 +++ packages/cli/src/utils/transform.ts | 230 ++++++++++++++++++++++++++++ packages/cli/test/detect.test.ts | 22 +-- 5 files changed, 293 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 573be1779..8f7752aa7 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -81,6 +81,7 @@ const FRAMEWORK_NAMES: Record = { vite: "Vite", tanstack: "TanStack Start", webpack: "Webpack", + astro: "Astro", unknown: "Unknown", }; @@ -96,7 +97,6 @@ const UNSUPPORTED_FRAMEWORK_NAMES: Record< string > = { remix: "Remix", - astro: "Astro", sveltekit: "SvelteKit", gatsby: "Gatsby", }; @@ -342,7 +342,10 @@ export const init = new Command() title: "Toggle (press to activate/deactivate)", value: "toggle", }, - { title: "Hold (hold key to keep active)", value: "hold" }, + { + title: "Hold (hold key to keep active)", + value: "hold", + }, ], initial: 0, }); diff --git a/packages/cli/src/utils/detect.ts b/packages/cli/src/utils/detect.ts index fb51ecb19..df9a96106 100644 --- a/packages/cli/src/utils/detect.ts +++ b/packages/cli/src/utils/detect.ts @@ -5,14 +5,15 @@ 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 NextRouterType = "app" | "pages" | "unknown"; -export type UnsupportedFramework = - | "remix" +export type Framework = + | "next" + | "vite" + | "tanstack" + | "webpack" | "astro" - | "sveltekit" - | "gatsby" - | null; + | "unknown"; +export type NextRouterType = "app" | "pages" | "unknown"; +export type UnsupportedFramework = "remix" | "sveltekit" | "gatsby" | null; export interface ProjectInfo { packageManager: PackageManager; @@ -72,6 +73,10 @@ export const detectFramework = (projectRoot: string): Framework => { return "vite"; } + if (allDependencies["astro"]) { + return "astro"; + } + if (allDependencies["webpack"]) { return "webpack"; } @@ -413,6 +418,19 @@ 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", "layouts", "Layout.astro"), + join(projectRoot, "src", "layouts", "_BaseLayout.astro"), + join(projectRoot, "src", "layouts", "_Layout.astro"), + join(projectRoot, "src", "layouts", "BaseLayout.astro"), + join(projectRoot, "layouts", "Layout.astro"), + join(projectRoot, "layouts", "_BaseLayout.astro"), + join(projectRoot, "layouts", "_Layout.astro"), + join(projectRoot, "layouts", "BaseLayout.astro"), + join(projectRoot, "src", "pages", "index.astro"), + join(projectRoot, "src", "pages", "_layout.astro"), + join(projectRoot, "pages", "index.astro"), + join(projectRoot, "Layout.astro"), + join(projectRoot, "index.astro"), ]; return filesToCheck.some(hasReactGrabInFile); @@ -450,10 +468,6 @@ export const detectUnsupportedFramework = ( return "remix"; } - if (allDependencies["astro"]) { - return "astro"; - } - if (allDependencies["@sveltejs/kit"]) { return "sveltekit"; } diff --git a/packages/cli/src/utils/templates.ts b/packages/cli/src/utils/templates.ts index 22e55f7e5..3b517deb3 100644 --- a/packages/cli/src/utils/templates.ts +++ b/packages/cli/src/utils/templates.ts @@ -141,4 +141,25 @@ export const TANSTACK_EFFECT_WITH_AGENT = (agent: AgentIntegration): string => { }, []);`; }; +export const ASTRO_EFFECT = `{import.meta.env.DEV && ( +\t\t\t +\t\t)}`; + +export const ASTRO_EFFECT_WITH_AGENT = (agent: AgentIntegration): string => { + if (agent === "none") return ASTRO_EFFECT; + + return `{import.meta.env.DEV && ( +\t\t\t +\t\t)}`; +}; + export const SCRIPT_IMPORT = 'import Script from "next/script";'; diff --git a/packages/cli/src/utils/transform.ts b/packages/cli/src/utils/transform.ts index af05367da..853b047be 100644 --- a/packages/cli/src/utils/transform.ts +++ b/packages/cli/src/utils/transform.ts @@ -14,6 +14,7 @@ import { TANSTACK_EFFECT_WITH_AGENT, VITE_SCRIPT_WITH_AGENT, WEBPACK_IMPORT_WITH_AGENT, + ASTRO_EFFECT_WITH_AGENT, type AgentIntegration, } from "./templates.js"; @@ -170,6 +171,26 @@ const findTanStackRootFile = (projectRoot: string): string | null => { return null; }; +const findAllAstroLayoutFiles = (projectRoot: string): string[] => { + const possiblePaths = [ + join(projectRoot, "src", "layouts", "Layout.astro"), + join(projectRoot, "src", "layouts", "_BaseLayout.astro"), + join(projectRoot, "src", "layouts", "_Layout.astro"), + join(projectRoot, "src", "layouts", "BaseLayout.astro"), + join(projectRoot, "src", "pages", "index.astro"), + join(projectRoot, "src", "pages", "_layout.astro"), + join(projectRoot, "layouts", "Layout.astro"), + join(projectRoot, "layouts", "_BaseLayout.astro"), + join(projectRoot, "layouts", "_Layout.astro"), + join(projectRoot, "layouts", "BaseLayout.astro"), + join(projectRoot, "pages", "index.astro"), + join(projectRoot, "Layout.astro"), + join(projectRoot, "index.astro"), + ]; + + return possiblePaths.filter((filePath) => existsSync(filePath)); +}; + const addAgentToExistingNextApp = ( originalContent: string, agent: AgentIntegration, @@ -761,6 +782,127 @@ const transformTanStack = ( }; }; +const addAgentToExistingAstro = ( + originalContent: string, + agent: AgentIntegration, + filePath: string, +): TransformResult => { + if (agent === "none") { + return { + success: true, + filePath, + message: "React Grab is already configured", + noChanges: true, + }; + } + + const agentPackage = `@react-grab/${agent}`; + if (originalContent.includes(agentPackage)) { + return { + success: true, + filePath, + message: `Agent ${agent} is already configured`, + noChanges: true, + }; + } + + const scriptBlock = ASTRO_EFFECT_WITH_AGENT(agent); + const headMatch = originalContent.match(/]*>/i); + + if (!headMatch) { + return { + success: false, + filePath, + message: "Could not find tag in the Astro file", + }; + } + + const indentation = " "; + + const newContent = originalContent.replace( + headMatch[0], + `${headMatch[0]}\n${indentation}${scriptBlock}`, + ); + + return { + success: true, + filePath, + message: `Add ${agent} agent to existing React Grab configuration`, + originalContent, + newContent, + }; +}; + +const transformAstro = ( + projectRoot: string, + agent: AgentIntegration, + reactGrabAlreadyConfigured: boolean, + force: boolean = false, +): TransformResult => { + const layoutFiles = findAllAstroLayoutFiles(projectRoot); + + if (layoutFiles.length === 0) { + return { + success: false, + filePath: "", + message: + "Could not find an Astro layout file.\n\n" + + "To set up React Grab with Astro, add this to your Layout.astro :\n\n" + + ' {import.meta.env.DEV && (\n \n )}', + }; + } + + for (const layoutPath of layoutFiles) { + const originalContent = readFileSync(layoutPath, "utf-8"); + const headMatch = originalContent.match(/]*>/i); + if (!headMatch) { + continue; + } + + let newContent = originalContent; + const hasReactGrabInFile = hasReactGrabCode(originalContent); + + if (!force && hasReactGrabInFile && reactGrabAlreadyConfigured) { + return addAgentToExistingAstro(originalContent, agent, layoutPath); + } + + if (!force && hasReactGrabInFile) { + return { + success: true, + filePath: layoutPath, + message: "React Grab is already installed in this file", + noChanges: true, + }; + } + + const scriptBlock = ASTRO_EFFECT_WITH_AGENT(agent); + const indentation = " "; + + newContent = originalContent.replace( + headMatch[0], + `${headMatch[0]}\n${indentation}${scriptBlock}`, + ); + + return { + success: true, + filePath: layoutPath, + message: + "Add React Grab" + (agent !== "none" ? ` with ${agent} agent` : ""), + originalContent, + newContent, + }; + } + + return { + success: false, + filePath: "", + message: + "Could not find an Astro layout file with a tag.\n\n" + + "To set up React Grab with Astro, add this to your Layout.astro :\n\n" + + ' {import.meta.env.DEV && (\n \n )}', + }; +}; + export const previewTransform = ( projectRoot: string, framework: Framework, @@ -810,6 +952,14 @@ export const previewTransform = ( force, ); + case "astro": + return transformAstro( + projectRoot, + agent, + reactGrabAlreadyConfigured, + force, + ); + default: return { success: false, @@ -1132,6 +1282,9 @@ const findReactGrabFile = ( return findTanStackRootFile(projectRoot); case "webpack": return findEntryFile(projectRoot); + case "astro": + const astroFiles = findAllAstroLayoutFiles(projectRoot); + return astroFiles.length > 0 ? astroFiles[0] : null; default: return null; } @@ -1287,6 +1440,39 @@ const addOptionsToTanStackImport = ( }; }; +const addOptionsToAstroScript = ( + originalContent: string, + options: ReactGrabOptions, + filePath: string, +): TransformResult => { + const optionsJson = formatOptionsAsJson(options); + const reactGrabImportMatch = originalContent.match( + /import\s*\(\s*["']react-grab["']\s*\)(?:\s*\.\s*then\s*\(\s*\([^)]*\)\s*=>[^}]*\}\s*\))?/, + ); + + if (!reactGrabImportMatch) { + return { + success: false, + filePath, + message: "Could not find React Grab import", + }; + } + + const newImport = `import("react-grab").then((m) => m.init(${optionsJson}))`; + const newContent = originalContent.replace( + reactGrabImportMatch[0], + newImport, + ); + + return { + success: true, + filePath, + message: "Update React Grab options", + originalContent, + newContent, + }; +}; + export const previewOptionsTransform = ( projectRoot: string, framework: Framework, @@ -1322,6 +1508,8 @@ export const previewOptionsTransform = ( return addOptionsToTanStackImport(originalContent, options, filePath); case "webpack": return addOptionsToWebpackImport(originalContent, options, filePath); + case "astro": + return addOptionsToAstroScript(originalContent, options, filePath); default: return { success: false, @@ -1466,6 +1654,46 @@ const removeAgentFromWebpack = ( }; }; +const removeAgentFromAstro = ( + originalContent: string, + agent: string, + filePath: string, +): TransformResult => { + const agentPackage = `@react-grab/${agent}`; + + if (!originalContent.includes(agentPackage)) { + return { + success: true, + filePath, + message: `Agent ${agent} is not configured in this file`, + noChanges: true, + }; + } + + const agentImportPattern = new RegExp( + `\\s*import\\s*\\(\\s*["']${agentPackage}/client["']\\s*\\);?`, + "g", + ); + + const newContent = originalContent.replace(agentImportPattern, ""); + + if (newContent === originalContent) { + return { + success: false, + filePath, + message: `Could not find agent ${agent} import to remove`, + }; + } + + return { + success: true, + filePath, + message: `Remove ${agent} agent`, + originalContent, + newContent, + }; +}; + const removeAgentFromTanStack = ( originalContent: string, agent: string, @@ -1534,6 +1762,8 @@ export const previewAgentRemoval = ( return removeAgentFromTanStack(originalContent, agent, filePath); case "webpack": return removeAgentFromWebpack(originalContent, agent, filePath); + case "astro": + return removeAgentFromAstro(originalContent, agent, filePath); default: return { success: false, diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 310aa4c27..7fcf01685 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -33,7 +33,9 @@ describe("detectFramework", () => { it("should detect Next.js", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( - JSON.stringify({ dependencies: { next: "14.0.0", react: "18.0.0" } }), + JSON.stringify({ + dependencies: { next: "14.0.0", react: "18.0.0" }, + }), ); expect(detectFramework("/test")).toBe("next"); @@ -48,6 +50,15 @@ describe("detectFramework", () => { expect(detectFramework("/test")).toBe("vite"); }); + it("should detect Astro", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ devDependencies: { astro: "4.0.0" } }), + ); + + expect(detectFramework("/test")).toBe("astro"); + }); + it("should detect Webpack", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( @@ -299,15 +310,6 @@ describe("detectUnsupportedFramework", () => { expect(detectUnsupportedFramework("/test")).toBe("remix"); }); - it("should detect Astro", () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue( - JSON.stringify({ devDependencies: { astro: "4.0.0" } }), - ); - - expect(detectUnsupportedFramework("/test")).toBe("astro"); - }); - it("should detect SvelteKit", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( From b0f66fd3ba3d717821f914d2146ebe1c6a28f340 Mon Sep 17 00:00:00 2001 From: Arda Soyturk <33841775+ardasoyturk@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:04:51 +0300 Subject: [PATCH 2/5] fix(cli): resolve Astro support issues - Move Astro detection before Vite to fix misidentification - Fix addAgentToExistingAstro to only add agent import, not full block - Fix addOptionsToAstroScript regex to match full .then() block - Fix findReactGrabFile to find file with React Grab code - Continue scanning Astro files when reactGrabAlreadyConfigured is true --- packages/cli/src/utils/detect.ts | 8 ++--- packages/cli/src/utils/transform.ts | 53 +++++++++++++++++------------ 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/utils/detect.ts b/packages/cli/src/utils/detect.ts index df9a96106..176990a1c 100644 --- a/packages/cli/src/utils/detect.ts +++ b/packages/cli/src/utils/detect.ts @@ -69,14 +69,14 @@ export const detectFramework = (projectRoot: string): Framework => { return "tanstack"; } - if (allDependencies["vite"]) { - return "vite"; - } - if (allDependencies["astro"]) { return "astro"; } + if (allDependencies["vite"]) { + return "vite"; + } + if (allDependencies["webpack"]) { return "webpack"; } diff --git a/packages/cli/src/utils/transform.ts b/packages/cli/src/utils/transform.ts index 853b047be..37f48d413 100644 --- a/packages/cli/src/utils/transform.ts +++ b/packages/cli/src/utils/transform.ts @@ -806,30 +806,31 @@ const addAgentToExistingAstro = ( }; } - const scriptBlock = ASTRO_EFFECT_WITH_AGENT(agent); - const headMatch = originalContent.match(/]*>/i); + const agentImport = `import("@react-grab/${agent}/client");`; + const reactGrabImportMatch = originalContent.match( + /import\s*\(\s*["']react-grab["']\s*\);?/, + ); - if (!headMatch) { + if (reactGrabImportMatch) { + const matchedText = reactGrabImportMatch[0]; + const hasSemicolon = matchedText.endsWith(";"); + const newContent = originalContent.replace( + matchedText, + `${hasSemicolon ? matchedText.slice(0, -1) : matchedText};\n ${agentImport}`, + ); return { - success: false, + success: true, filePath, - message: "Could not find tag in the Astro file", + message: `Add ${agent} agent`, + originalContent, + newContent, }; } - const indentation = " "; - - const newContent = originalContent.replace( - headMatch[0], - `${headMatch[0]}\n${indentation}${scriptBlock}`, - ); - return { - success: true, + success: false, filePath, - message: `Add ${agent} agent to existing React Grab configuration`, - originalContent, - newContent, + message: "Could not find React Grab import in the Astro file", }; }; @@ -875,6 +876,10 @@ const transformAstro = ( }; } + if (reactGrabAlreadyConfigured) { + continue; + } + const scriptBlock = ASTRO_EFFECT_WITH_AGENT(agent); const indentation = " "; @@ -1284,6 +1289,12 @@ const findReactGrabFile = ( return findEntryFile(projectRoot); case "astro": const astroFiles = findAllAstroLayoutFiles(projectRoot); + for (const file of astroFiles) { + const content = readFileSync(file, "utf-8"); + if (hasReactGrabCode(content)) { + return file; + } + } return astroFiles.length > 0 ? astroFiles[0] : null; default: return null; @@ -1445,12 +1456,11 @@ const addOptionsToAstroScript = ( options: ReactGrabOptions, filePath: string, ): TransformResult => { - const optionsJson = formatOptionsAsJson(options); - const reactGrabImportMatch = originalContent.match( - /import\s*\(\s*["']react-grab["']\s*\)(?:\s*\.\s*then\s*\(\s*\([^)]*\)\s*=>[^}]*\}\s*\))?/, + const reactGrabImportWithInitMatch = originalContent.match( + /import\s*\(\s*["']react-grab["']\s*\)(?:\.then\s*\(\s*\(m\)\s*=>\s*m\.init\s*\([^)]*\)\s*\))?/, ); - if (!reactGrabImportMatch) { + if (!reactGrabImportWithInitMatch) { return { success: false, filePath, @@ -1458,9 +1468,10 @@ const addOptionsToAstroScript = ( }; } + const optionsJson = formatOptionsAsJson(options); const newImport = `import("react-grab").then((m) => m.init(${optionsJson}))`; const newContent = originalContent.replace( - reactGrabImportMatch[0], + reactGrabImportWithInitMatch[0], newImport, ); From f48382ca1f0bc9a1d851a738b46a336040f7aa1e Mon Sep 17 00:00:00 2001 From: Arda Soyturk <33841775+ardasoyturk@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:07:56 +0300 Subject: [PATCH 3/5] test: add Astro vs Vite priority detection test --- packages/cli/test/detect.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 7fcf01685..2428a0b0a 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -59,6 +59,15 @@ describe("detectFramework", () => { expect(detectFramework("/test")).toBe("astro"); }); + it("should prioritize Astro over Vite when both are present", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ devDependencies: { astro: "4.0.0", vite: "5.0.0" } }), + ); + + expect(detectFramework("/test")).toBe("astro"); + }); + it("should detect Webpack", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( From 02006cb77f2045866b6bd853622099ec881ad527 Mon Sep 17 00:00:00 2001 From: Arda Soyturk <33841775+ardasoyturk@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:28:28 +0300 Subject: [PATCH 4/5] fix(cli): resolve additional Astro support issues - Add regex escaping for agent package in removeAgentFromAstro - Add Astro to supported frameworks error message --- packages/cli/src/commands/init.ts | 2 +- packages/cli/src/utils/transform.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 8f7752aa7..9a7cb628e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -914,7 +914,7 @@ export const init = new Command() frameworkSpinner.fail("Could not detect a supported framework."); logger.break(); logger.log( - "React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.", + "React Grab supports Next.js, Vite, TanStack Start, Astro, and Webpack projects.", ); logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`); logger.break(); diff --git a/packages/cli/src/utils/transform.ts b/packages/cli/src/utils/transform.ts index 37f48d413..2f18b21d2 100644 --- a/packages/cli/src/utils/transform.ts +++ b/packages/cli/src/utils/transform.ts @@ -1682,7 +1682,7 @@ const removeAgentFromAstro = ( } const agentImportPattern = new RegExp( - `\\s*import\\s*\\(\\s*["']${agentPackage}/client["']\\s*\\);?`, + `\\s*import\\s*\\(\\s*["']${agentPackage.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/client["']\\s*\\);?`, "g", ); From 84d719535d6380576c110fee8892eaa0f0b1741b Mon Sep 17 00:00:00 2001 From: Arda Soyturk <33841775+ardasoyturk@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:27:04 +0300 Subject: [PATCH 5/5] fix(cli): resolve force guard and indentation issues in Astro support --- packages/cli/src/utils/templates.ts | 26 +++++++++++++------------- packages/cli/src/utils/transform.ts | 5 +++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/utils/templates.ts b/packages/cli/src/utils/templates.ts index 3b517deb3..ad3962fbf 100644 --- a/packages/cli/src/utils/templates.ts +++ b/packages/cli/src/utils/templates.ts @@ -142,24 +142,24 @@ export const TANSTACK_EFFECT_WITH_AGENT = (agent: AgentIntegration): string => { }; export const ASTRO_EFFECT = `{import.meta.env.DEV && ( -\t\t\t -\t\t)}`; + +)}`; export const ASTRO_EFFECT_WITH_AGENT = (agent: AgentIntegration): string => { if (agent === "none") return ASTRO_EFFECT; return `{import.meta.env.DEV && ( -\t\t\t -\t\t)}`; + +)}`; }; export const SCRIPT_IMPORT = 'import Script from "next/script";'; diff --git a/packages/cli/src/utils/transform.ts b/packages/cli/src/utils/transform.ts index 2f18b21d2..35fd5f614 100644 --- a/packages/cli/src/utils/transform.ts +++ b/packages/cli/src/utils/transform.ts @@ -816,7 +816,8 @@ const addAgentToExistingAstro = ( const hasSemicolon = matchedText.endsWith(";"); const newContent = originalContent.replace( matchedText, - `${hasSemicolon ? matchedText.slice(0, -1) : matchedText};\n ${agentImport}`, + `${hasSemicolon ? matchedText.slice(0, -1) : matchedText}; + ${agentImport}`, ); return { success: true, @@ -876,7 +877,7 @@ const transformAstro = ( }; } - if (reactGrabAlreadyConfigured) { + if (!force && reactGrabAlreadyConfigured) { continue; }