diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 573be1779..9a7cb628e 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, }); @@ -911,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/detect.ts b/packages/cli/src/utils/detect.ts index fb51ecb19..176990a1c 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; @@ -68,6 +69,10 @@ export const detectFramework = (projectRoot: string): Framework => { return "tanstack"; } + if (allDependencies["astro"]) { + return "astro"; + } + if (allDependencies["vite"]) { return "vite"; } @@ -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..ad3962fbf 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 && ( + +)}`; + +export const ASTRO_EFFECT_WITH_AGENT = (agent: AgentIntegration): string => { + if (agent === "none") return ASTRO_EFFECT; + + return `{import.meta.env.DEV && ( + +)}`; +}; + 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..35fd5f614 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,133 @@ 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 agentImport = `import("@react-grab/${agent}/client");`; + const reactGrabImportMatch = originalContent.match( + /import\s*\(\s*["']react-grab["']\s*\);?/, + ); + + if (reactGrabImportMatch) { + const matchedText = reactGrabImportMatch[0]; + const hasSemicolon = matchedText.endsWith(";"); + const newContent = originalContent.replace( + matchedText, + `${hasSemicolon ? matchedText.slice(0, -1) : matchedText}; + ${agentImport}`, + ); + return { + success: true, + filePath, + message: `Add ${agent} agent`, + originalContent, + newContent, + }; + } + + return { + success: false, + filePath, + message: "Could not find React Grab import in the Astro file", + }; +}; + +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, + }; + } + + if (!force && reactGrabAlreadyConfigured) { + continue; + } + + 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 +958,14 @@ export const previewTransform = ( force, ); + case "astro": + return transformAstro( + projectRoot, + agent, + reactGrabAlreadyConfigured, + force, + ); + default: return { success: false, @@ -1132,6 +1288,15 @@ const findReactGrabFile = ( return findTanStackRootFile(projectRoot); case "webpack": 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; } @@ -1287,6 +1452,39 @@ const addOptionsToTanStackImport = ( }; }; +const addOptionsToAstroScript = ( + originalContent: string, + options: ReactGrabOptions, + filePath: string, +): TransformResult => { + const reactGrabImportWithInitMatch = originalContent.match( + /import\s*\(\s*["']react-grab["']\s*\)(?:\.then\s*\(\s*\(m\)\s*=>\s*m\.init\s*\([^)]*\)\s*\))?/, + ); + + if (!reactGrabImportWithInitMatch) { + return { + success: false, + filePath, + message: "Could not find React Grab import", + }; + } + + const optionsJson = formatOptionsAsJson(options); + const newImport = `import("react-grab").then((m) => m.init(${optionsJson}))`; + const newContent = originalContent.replace( + reactGrabImportWithInitMatch[0], + newImport, + ); + + return { + success: true, + filePath, + message: "Update React Grab options", + originalContent, + newContent, + }; +}; + export const previewOptionsTransform = ( projectRoot: string, framework: Framework, @@ -1322,6 +1520,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 +1666,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.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/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 +1774,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..2428a0b0a 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,24 @@ 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 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( @@ -299,15 +319,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(