diff --git a/.vscode/.debug.script.mjs b/.vscode/.debug.script.mjs index 9ca93363c..a641e3186 100644 --- a/.vscode/.debug.script.mjs +++ b/.vscode/.debug.script.mjs @@ -13,11 +13,10 @@ fs.writeFileSync(path.join(__dirname, '.debug.env'), envContent.join('\n')) // bootstrap spawn( - // TODO: terminate `npm run dev` when Debug exits. - process.platform === 'win32' ? 'npm.cmd' : 'npm', - ['run', 'dev'], - { - stdio: 'inherit', - env: Object.assign(process.env, { VSCODE_DEBUG: 'true' }), - }, + process.platform === 'win32' ? 'npm.cmd' : 'npm', + ['run', 'dev'], + { + stdio: 'inherit', + env: Object.assign(process.env, { VSCODE_DEBUG: 'true' }), + }, ) \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 85d09cdea..0b7825179 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,31 +1,30 @@ { - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "tasks": [ - { - "label": "Before Debug", - "type": "shell", - "command": "node .vscode/.debug.script.mjs", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "fileLocation": "relative", - "pattern": { - // TODO: correct "regexp" - "regexp": "^([a-zA-Z]\\:\/?([\\w\\-]\/?)+\\.\\w+):(\\d+):(\\d+): (ERROR|WARNING)\\: (.*)$", - "file": 1, - "line": 3, - "column": 4, - "code": 5, - "message": 6 - }, - "background": { - "activeOnStart": true, - "beginsPattern": "^.*VITE v.* ready in \\d* ms.*$", - "endsPattern": "^.*\\[startup\\] Electron App.*$" + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Before Debug", + "type": "shell", + "command": "node .vscode/.debug.script.mjs", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "fileLocation": "relative", + "pattern": { + "regexp": "^([a-zA-Z]\\:\/?([\\w\\-]\/?)+\\.\\w+):(\\d+):(\\d+): (ERROR|WARNING)\\: (.*)$", + "file": 1, + "line": 3, + "column": 4, + "code": 5, + "message": 6 + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^.*VITE v.* ready in \\d* ms.*$", + "endsPattern": "^.*\\[startup\\] Electron App.*$" + } + } } - } - } - ] + ] } \ No newline at end of file diff --git a/app/bun.lockb b/app/bun.lockb index 300a12856..44af83436 100755 Binary files a/app/bun.lockb and b/app/bun.lockb differ diff --git a/app/common/constants.ts b/app/common/constants.ts index 29132c0e4..82d2a5d1a 100644 --- a/app/common/constants.ts +++ b/app/common/constants.ts @@ -41,6 +41,7 @@ export enum WebviewChannels { } export enum MainChannels { + OPEN_IN_EXPLORER = 'open-in-explorer', OPEN_EXTERNAL_WINDOW = 'open-external-window', QUIT_AND_INSTALL = 'quit-and-update-app', UPDATE_DOWNLOADED = 'update-downloaded', @@ -81,6 +82,14 @@ export enum MainChannels { UPDATE_USER_SETTINGS = 'update-user-settings', UPDATE_APP_STATE = 'update-app-state', UPDATE_PROJECTS = 'update-projects', + + // Create + CREATE_NEW_PROJECT = 'create-new-project', + CREATE_NEW_PROJECT_CALLBACK = 'create-new-project-callback', + VERIFY_PROJECT = 'verify-project', + VERIFY_PROJECT_CALLBACK = 'verify-project-callback', + SETUP_PROJECT = 'setup-project', + SETUP_PROJECT_CALLBACK = 'setup-project-callback', } export enum Links { diff --git a/app/electron/main/events/create.ts b/app/electron/main/events/create.ts new file mode 100644 index 000000000..c2a353ae5 --- /dev/null +++ b/app/electron/main/events/create.ts @@ -0,0 +1,50 @@ +import { + CreateCallback, + CreateStage, + SetupCallback, + SetupStage, + VerifyCallback, + VerifyStage, + createProject, + setupProject, + verifyProject, +} from '@onlook/utils'; +import { ipcMain } from 'electron'; +import { mainWindow } from '..'; +import { MainChannels } from '/common/constants'; + +export function listenForCreateMessages() { + ipcMain.handle(MainChannels.CREATE_NEW_PROJECT, (e: Electron.IpcMainInvokeEvent, args) => { + const progressCallback: CreateCallback = (stage: CreateStage, message: string) => { + mainWindow?.webContents.send(MainChannels.CREATE_NEW_PROJECT_CALLBACK, { + stage, + message, + }); + }; + + const { name, path } = args as { name: string; path: string }; + return createProject(name, path, progressCallback); + }); + + ipcMain.handle(MainChannels.VERIFY_PROJECT, (e: Electron.IpcMainInvokeEvent, args: string) => { + const progressCallback: VerifyCallback = (stage: VerifyStage, message: string) => { + mainWindow?.webContents.send(MainChannels.VERIFY_PROJECT_CALLBACK, { + stage, + message, + }); + }; + const path = args as string; + return verifyProject(path, progressCallback); + }); + + ipcMain.handle(MainChannels.SETUP_PROJECT, (e: Electron.IpcMainInvokeEvent, args: string) => { + const progressCallback: SetupCallback = (stage: SetupStage, message: string) => { + mainWindow?.webContents.send(MainChannels.SETUP_PROJECT_CALLBACK, { + stage, + message, + }); + }; + const path = args as string; + return setupProject(path, progressCallback); + }); +} diff --git a/app/electron/main/events/index.ts b/app/electron/main/events/index.ts index c3dd0d64f..73a6756f6 100644 --- a/app/electron/main/events/index.ts +++ b/app/electron/main/events/index.ts @@ -3,6 +3,7 @@ import { updater } from '../update'; import { listenForAnalyticsMessages } from './analytics'; import { listenForAuthMessages } from './auth'; import { listenForCodeMessages } from './code'; +import { listenForCreateMessages } from './create'; import { listenForStorageMessages } from './storage'; import { listenForTunnelMessages } from './tunnel'; import { MainChannels } from '/common/constants'; @@ -14,9 +15,17 @@ export function listenForIpcMessages() { listenForCodeMessages(); listenForStorageMessages(); listenForAuthMessages(); + listenForCreateMessages(); } function listenForGeneralMessages() { + ipcMain.handle( + MainChannels.OPEN_IN_EXPLORER, + (e: Electron.IpcMainInvokeEvent, args: string) => { + return shell.showItemInFolder(args); + }, + ); + ipcMain.handle( MainChannels.OPEN_EXTERNAL_WINDOW, (e: Electron.IpcMainInvokeEvent, args: string) => { diff --git a/app/package.json b/app/package.json index 530fdc031..676472bd6 100644 --- a/app/package.json +++ b/app/package.json @@ -36,6 +36,7 @@ "remove_tag": "./scripts/remove_tag.sh" }, "dependencies": { + "@onlook/utils": "0.0.3", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-context-menu": "^2.2.1", @@ -89,8 +90,8 @@ "@eslint/compat": "^1.1.1", "@eslint/js": "^9.7.0", "@playwright/test": "^1.42.1", - "@types/babel__traverse": "^7.20.6", "@types/babel-generator": "^6.25.8", + "@types/babel__traverse": "^7.20.6", "@types/bun": "^1.1.6", "@types/css-tree": "^2.3.8", "@types/culori": "^2.1.0", diff --git a/app/src/routes/projects/ProjectsTab/Create/Load/Name.tsx b/app/src/routes/projects/ProjectsTab/Create/Load/Name.tsx new file mode 100644 index 000000000..33227fe3a --- /dev/null +++ b/app/src/routes/projects/ProjectsTab/Create/Load/Name.tsx @@ -0,0 +1,63 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { StepProps } from '..'; +import { getRandomPlaceholder } from '../../../helpers'; + +export const LoadNameProject = ({ + props: { projectData, currentStep, setProjectData, totalSteps, prevStep, nextStep }, +}: { + props: StepProps; +}) => { + function setProjectName(name: string) { + setProjectData({ + ...projectData, + name, + }); + } + return ( + + + {'Let’s name your project'} + + {"We'll install the necessary dependencies for you"} + + + +
+ + setProjectName(e.currentTarget.value)} + /> +
+
+ +

{`${currentStep + 1} of ${totalSteps}`}

+
+ + +
+
+
+ ); +}; diff --git a/app/src/routes/projects/ProjectsTab/Create/Load/SelectFolder.tsx b/app/src/routes/projects/ProjectsTab/Create/Load/SelectFolder.tsx index d762bb411..b77486022 100644 --- a/app/src/routes/projects/ProjectsTab/Create/Load/SelectFolder.tsx +++ b/app/src/routes/projects/ProjectsTab/Create/Load/SelectFolder.tsx @@ -35,6 +35,15 @@ export const LoadSelectFolder = ({ }); } + function verifyFolder() { + window.api.invoke(MainChannels.VERIFY_PROJECT, projectData.folderPath); + nextStep(); + } + + function handleClickPath() { + window.api.invoke(MainChannels.OPEN_IN_EXPLORER, projectData.folderPath); + } + return ( @@ -43,10 +52,15 @@ export const LoadSelectFolder = ({ {projectData.folderPath ? ( -
+

{projectData.name}

-

{projectData.folderPath}

+
+
+ {state === StepState.INSTALLED ? ( + + ) : ( + + )} +
+ ); + } + + function renderTitle() { + if (state === StepState.VERIFYING) { + return 'Verifying project...'; + } else if (state === StepState.INSTALLING) { + return 'Setting up Onlook...'; + } else if (state === StepState.INSTALLED) { + return 'Onlook is installed'; + } else if (state === StepState.NOT_INSTALLED) { + return 'Onlook is not installed'; + } else if (state === StepState.ERROR) { + return 'Something went wrong'; + } + } + + function renderDescription() { + if (state === StepState.VERIFYING) { + return 'Checking your dependencies and configurations'; + } else if (state === StepState.INSTALLING) { + return 'Setting up your dependencies and configurations'; + } else if (state === StepState.NOT_INSTALLED) { + return 'It takes one second to install Onlook on your project'; + } else if (state === StepState.INSTALLED) { + return 'Your project is all set up'; + } else { + return progressMessage || 'Please try again or contact support'; + } + } + + function renderPrimaryButton() { + if ( + state === StepState.INSTALLING || + state === StepState.VERIFYING || + state === StepState.ERROR + ) { + return ( + + ); + } else if (state === StepState.INSTALLED) { + return ( + + ); + } else if (state === StepState.NOT_INSTALLED) { + return ( + + ); + } } return ( - - {isInstalled ? 'Onlook is installed' : 'Onlook is not installed'} - - - {isInstalled - ? 'Your project is all set up' - : 'It takes one second to install Onlook on your project'} - + {renderTitle()} + {renderDescription()} - -
-
-

{projectData.name}

-

{projectData.folderPath}

-
- {isInstalled ? ( - - ) : ( - - )} -
+ + {renderMainContent()}

{`${currentStep + 1} of ${totalSteps}`}

- - {isInstalled ? ( - - ) : ( - - )} + {renderPrimaryButton()}
diff --git a/app/src/routes/projects/ProjectsTab/Create/Name.tsx b/app/src/routes/projects/ProjectsTab/Create/New/Name.tsx similarity index 74% rename from app/src/routes/projects/ProjectsTab/Create/Name.tsx rename to app/src/routes/projects/ProjectsTab/Create/New/Name.tsx index a0ca9cb0e..b68c7b9bb 100644 --- a/app/src/routes/projects/ProjectsTab/Create/Name.tsx +++ b/app/src/routes/projects/ProjectsTab/Create/New/Name.tsx @@ -9,20 +9,14 @@ import { } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { StepProps } from '.'; -import { useState, useEffect } from 'react'; +import { StepProps } from '..'; +import { getRandomPlaceholder } from '../../../helpers'; -export const NameProjectStep = ({ +export const NewNameProject = ({ props: { projectData, currentStep, setProjectData, totalSteps, prevStep, nextStep }, }: { props: StepProps; }) => { - const [placeholderIndex, setPlaceholderIndex] = useState(0); - - useEffect(() => { - setPlaceholderIndex(Math.floor(Math.random() * placeholderTitles.length)); - }, []); - function setProjectName(name: string) { setProjectData({ ...projectData, @@ -42,7 +36,7 @@ export const NameProjectStep = ({ setProjectName(e.currentTarget.value)} /> @@ -67,16 +61,3 @@ export const NameProjectStep = ({
); }; - -const placeholderTitles = [ - 'The greatest app in the world', - 'My epic project', - 'The greatest project ever', - 'A revolutionary idea', - 'Project X', - 'Genius React App', - 'The next billion dollar idea', - 'Mind-blowingly cool app', - 'Earth-shatteringly great app', - 'Moonshot project', -]; diff --git a/app/src/routes/projects/ProjectsTab/Create/New/SelectFolder.tsx b/app/src/routes/projects/ProjectsTab/Create/New/SelectFolder.tsx index 3e294b32c..114923957 100644 --- a/app/src/routes/projects/ProjectsTab/Create/New/SelectFolder.tsx +++ b/app/src/routes/projects/ProjectsTab/Create/New/SelectFolder.tsx @@ -48,6 +48,20 @@ export const NewSelectFolder = ({ prevStep(); } + function handleSetupProject() { + if (!projectData.folderPath) { + console.error('Folder path is missing'); + return; + } + const newFolderName = projectData.folderPath?.split('/').pop() || ''; + const pathToFolders = projectData.folderPath?.split('/').slice(0, -1).join('/'); + window.api.invoke(MainChannels.CREATE_NEW_PROJECT, { + name: newFolderName, + path: pathToFolders, + }); + nextStep(); + } + return ( @@ -94,7 +108,7 @@ export const NewSelectFolder = ({ - + )} diff --git a/app/src/routes/projects/ProjectsTab/Create/index.tsx b/app/src/routes/projects/ProjectsTab/Create/index.tsx index e81820220..cf474e99a 100644 --- a/app/src/routes/projects/ProjectsTab/Create/index.tsx +++ b/app/src/routes/projects/ProjectsTab/Create/index.tsx @@ -1,10 +1,11 @@ import { useProjectsManager } from '@/components/Context'; import { useEffect, useState } from 'react'; import { CreateMethod } from '../..'; +import { LoadNameProject } from './Load/Name'; import { LoadSelectFolder } from './Load/SelectFolder'; import { LoadSetUrl } from './Load/SetUrl'; import { LoadVerifyProject } from './Load/Verify'; -import { NameProjectStep } from './Name'; +import { NewNameProject } from './New/Name'; import { NewRunProject } from './New/Run'; import { NewSelectFolder } from './New/SelectFolder'; import { NewSetupProject } from './New/Setup'; @@ -32,7 +33,9 @@ const CreateProject = ({ const TOTAL_LOAD_STEPS = 4; const [currentStep, setCurrentStep] = useState(0); const [totalSteps, setTotalSteps] = useState(0); - const [projectData, setProjectData] = useState>({}); + const [projectData, setProjectData] = useState>({ + url: 'http://localhost:3000', + }); useEffect(() => { setCurrentStep(0); @@ -84,17 +87,17 @@ const CreateProject = ({ return ; } if (currentStep === 1) { - return ; + return ; } if (currentStep === 2) { - return ; + return ; } if (currentStep === 3) { return ; } } else if (createMethod === CreateMethod.NEW) { if (currentStep === 0) { - return ; + return ; } if (currentStep === 1) { return ; diff --git a/app/src/routes/projects/ProjectsTab/Select/Carousel.tsx b/app/src/routes/projects/ProjectsTab/Select/Carousel.tsx index 4d15c5cf5..636d30c22 100644 --- a/app/src/routes/projects/ProjectsTab/Select/Carousel.tsx +++ b/app/src/routes/projects/ProjectsTab/Select/Carousel.tsx @@ -1,6 +1,7 @@ import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; import useEmblaCarousel from 'embla-carousel-react'; -import React, { useCallback, useEffect, useState } from 'react'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Project } from '/common/models/project'; interface EmblaCarouselProps { @@ -9,6 +10,7 @@ interface EmblaCarouselProps { } const EmblaCarousel: React.FC = ({ slides, onSlideChange }) => { + const WHEEL_SENSITIVITY = 10; const [emblaRef, emblaApi] = useEmblaCarousel({ axis: 'y', loop: false, @@ -55,26 +57,33 @@ const EmblaCarousel: React.FC = ({ slides, onSlideChange }) }; window.addEventListener('keydown', handleKeyDown); - - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; + return () => window.removeEventListener('keydown', handleKeyDown); }, [scrollPrev, scrollNext]); + const debouncedScroll = useMemo( + () => + debounce( + (deltaY: number) => { + if (deltaY > 0) { + scrollNext(); + } else { + scrollPrev(); + } + }, + 50, + { leading: true, trailing: false }, + ), + [scrollNext, scrollPrev], + ); + const handleWheel = useCallback( (e: React.WheelEvent) => { e.preventDefault(); - const threshold = 50; // Adjust this value to change the sensitivity - - if (Math.abs(e.deltaY) > threshold) { - if (e.deltaY > 0) { - scrollNext(); - } else { - scrollPrev(); - } + if (Math.abs(e.deltaY) > WHEEL_SENSITIVITY) { + debouncedScroll(e.deltaY); } }, - [scrollNext, scrollPrev], + [debouncedScroll], ); return ( @@ -91,7 +100,7 @@ const EmblaCarousel: React.FC = ({ slides, onSlideChange }) }} >
- {slides.map((slide, index) => ( + {slides.map((slide) => (
(IDE.VS_CODE); - const MESSAGES = [ - 'Set some dials and knobs and stuff', - 'Fine-tune how you want to build', - 'Swap out your default code editor if you dare', - "You shouldn't be worried about this stuff, yet here you are", - 'Mostly a formality', - "What's this button do?", - 'Customize how you want to build', - 'Thanks for stopping by the Settings page', - 'This is where the good stuff is', - 'Open 24 hours, 7 days a week', - '*beep boop*', - "Welcome. We've been expecting you.", - ]; - useEffect(() => { window.api.invoke(MainChannels.GET_USER_SETTINGS).then((res) => { const settings: UserSettings = res as UserSettings; @@ -40,8 +26,6 @@ export default function SettingsTab() { }); }, []); - const OPENING_MESSAGE = MESSAGES[Math.floor(Math.random() * MESSAGES.length)]; - function updateIde(ide: IDE) { window.api.invoke(MainChannels.UPDATE_USER_SETTINGS, { ideType: ide.type }); setIde(ide); @@ -56,7 +40,7 @@ export default function SettingsTab() {

{'Settings'}

-

{OPENING_MESSAGE}

+

{getRandomSettingsMessage()}

diff --git a/app/src/routes/projects/helpers.ts b/app/src/routes/projects/helpers.ts new file mode 100644 index 000000000..20daa4cb7 --- /dev/null +++ b/app/src/routes/projects/helpers.ts @@ -0,0 +1,35 @@ +export const PLACEHOLDER_NAMES = [ + 'The greatest app in the world', + 'My epic project', + 'The greatest project ever', + 'A revolutionary idea', + 'Project X', + 'Genius React App', + 'The next billion dollar idea', + 'Mind-blowingly cool app', + 'Earth-shatteringly great app', + 'Moonshot project', +]; + +export const SETTINGS_MESSAGE = [ + 'Set some dials and knobs and stuff', + 'Fine-tune how you want to build', + 'Swap out your default code editor if you dare', + "You shouldn't be worried about this stuff, yet here you are", + 'Mostly a formality', + "What's this button do?", + 'Customize how you want to build', + 'Thanks for stopping by the Settings page', + 'This is where the good stuff is', + 'Open 24 hours, 7 days a week', + '*beep boop*', + "Welcome. We've been expecting you.", +]; + +export function getRandomPlaceholder() { + return PLACEHOLDER_NAMES[Math.floor(Math.random() * PLACEHOLDER_NAMES.length)]; +} + +export function getRandomSettingsMessage() { + return SETTINGS_MESSAGE[Math.floor(Math.random() * SETTINGS_MESSAGE.length)]; +} diff --git a/app/vite.config.ts b/app/vite.config.ts index e04a61c21..ef0fd2051 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -7,12 +7,13 @@ import pkg from './package.json'; // https://vitejs.dev/config/ export default defineConfig(({ command }) => { - rmSync('dist-electron', { recursive: true, force: true }); - + rmSync('dist-electron', { + recursive: true, + force: true, + }); const isServe = command === 'serve'; const isBuild = command === 'build'; const sourcemap = isServe || !!process.env.VSCODE_DEBUG; - return { resolve: { alias: { diff --git a/bun.lockb b/bun.lockb index 59dd36252..aa33e133e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli/bun.lockb b/cli/bun.lockb index b2e51cdb3..4c2b5409f 100755 Binary files a/cli/bun.lockb and b/cli/bun.lockb differ diff --git a/cli/package.json b/cli/package.json index f4ff291b6..a4638411f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,52 +1,42 @@ { - "name": "onlook", - "description": "The Onlook Command Line Interface", - "version": "0.0.8", - "main": "dist/api/index.mjs", - "module": "dist/api/index.mjs", - "bin": { - "onlook": "dist/cli/index.cjs" - }, - "directories": { - "test": "tests" - }, - "scripts": { - "dev": "npm run esbuild -- --watch", - "esbuild": "esbuild src/cli/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli/index.cjs", - "build": "tsc --noEmit --skipLibCheck src/cli/index.ts && esbuild src/cli/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli/index.cjs --define:PACKAGE_VERSION=\\\"$npm_package_version\\\"", - "build:notype": "npm run esbuild", - "build:api": "tsc --noEmit --skipLibCheck src/api/index.ts && esbuild src/api/index.ts --bundle --platform=node --format=esm --outfile=dist/api/index.mjs", - "test": "bun test" - }, - "keywords": [ - "npx", - "onlook", - "setup", - "plugins" - ], - "author": { - "name": "Onlook", - "email": "contact@onlook.dev" - }, - "license": "Apache-2.0", - "homepage": "https://onlook.dev", - "devDependencies": { - "@types/babel__generator": "^7.6.8", - "@types/babel__traverse": "^7.20.6", - "@types/bun": "latest", - "@types/degit": "^2.8.6", - "esbuild": "^0.23.1", - "tslib": "^2.6.3", - "typescript": "^5.0.0" - }, - "dependencies": { - "@babel/generator": "^7.14.5", - "@babel/parser": "^7.14.3", - "@babel/traverse": "^7.14.5", - "@babel/types": "^7.14.5", - "commander": "^12.1.0", - "degit": "^2.8.4", - "glob": "^11.0.0", - "ora": "^8.1.0" - } + "name": "onlook", + "description": "The Onlook Command Line Interface", + "version": "0.0.8", + "main": "dist/index.js", + "bin": { + "onlook": "dist/index.cjs" + }, + "directories": { + "test": "tests" + }, + "scripts": { + "esbuild": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.cjs", + "dev": "npm run esbuild -- --watch", + "build": "tsc --noEmit --skipLibCheck src/index.ts && esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.cjs --define:PACKAGE_VERSION=\\\"$npm_package_version\\\"", + "build:notype": "npm run esbuild", + "test": "bun test" + }, + "keywords": [ + "npx", + "onlook", + "setup", + "plugins" + ], + "author": { + "name": "Onlook", + "email": "contact@onlook.dev" + }, + "license": "Apache-2.0", + "homepage": "https://onlook.dev", + "devDependencies": { + "@types/bun": "latest", + "esbuild": "^0.23.1", + "tslib": "^2.6.3", + "typescript": "^5.0.0" + }, + "dependencies": { + "@onlook/utils": "^0.0.3", + "commander": "^12.1.0", + "ora": "^8.1.0" + } } \ No newline at end of file diff --git a/cli/src/api/index.ts b/cli/src/api/index.ts deleted file mode 100644 index e21f5d5f6..000000000 --- a/cli/src/api/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ApiResponse } from "../models"; - -export function isOnlookEnabled(folder: string): Promise> { - return Promise.resolve({ - status: 'success', - data: true - }); -} - -export function createProject(folder: string, name: string): Promise { - return Promise.resolve({ - status: 'success' - }); -} \ No newline at end of file diff --git a/cli/src/create/constant.ts b/cli/src/create/constant.ts deleted file mode 100644 index ed2c05d61..000000000 --- a/cli/src/create/constant.ts +++ /dev/null @@ -1 +0,0 @@ -export const NEXT_TEMPLATE_REPO = 'onlook-dev/starter'; diff --git a/cli/src/create/index.ts b/cli/src/create/index.ts index d0066f961..cb6c958b2 100644 --- a/cli/src/create/index.ts +++ b/cli/src/create/index.ts @@ -1,54 +1,36 @@ -import { exec } from 'child_process'; -import * as fs from 'fs'; +import { createProject, CreateStage, type CreateCallback } from '@onlook/utils'; import ora from 'ora'; -import * as path from 'path'; -import { promisify } from 'util'; -import { NEXT_TEMPLATE_REPO } from './constant'; - -// @ts-ignore -import degit from 'degit'; - -const execAsync = promisify(exec); - -export async function create(projectName: string) { - const targetPath = path.join(process.cwd(), projectName); +export async function create(projectName: string): Promise { console.log(`Creating a new Onlook project: ${projectName}`); - - // Check if the directory already exists - if (fs.existsSync(targetPath)) { - console.error(`Error: Directory ${projectName} already exists.`); - process.exit(1); + const targetPath = process.cwd(); + const spinner = ora('Initializing project...').start(); + + const progressCallback: CreateCallback = (stage: CreateStage, message: string) => { + switch (stage) { + case CreateStage.CLONING: + spinner.text = 'Cloning template...'; + break; + case CreateStage.INSTALLING: + spinner.text = 'Installing dependencies...'; + break; + case CreateStage.COMPLETE: + spinner.succeed('Project created successfully!'); + console.log('\nTo get started:'); + console.log(` cd ${projectName}`); + console.log(' npm run dev'); + break; + case CreateStage.ERROR: + spinner.fail(message); + break; + } } - const spinner = ora('Initializing project').start(); - try { - // Clone the template using degit - spinner.text = 'Cloning template'; - const emitter = degit(NEXT_TEMPLATE_REPO, { - cache: false, - force: true, - verbose: true, - }); - - await emitter.clone(targetPath); - - // Change to the project directory - process.chdir(targetPath); - - // Install dependencies - spinner.text = 'Installing dependencies'; - await execAsync('npm install'); - - spinner.succeed('Project created successfully'); - - console.log('\nTo get started:'); - console.log(` cd ${projectName}`); - console.log(' npm run dev'); + await createProject(projectName, targetPath, progressCallback); } catch (error) { - spinner.fail('Project creation failed'); - console.error('An error occurred:', error); + spinner.fail('An error occurred'); + console.error('Error details:', error); process.exit(1); } } \ No newline at end of file diff --git a/cli/src/cli/index.ts b/cli/src/index.ts similarity index 91% rename from cli/src/cli/index.ts rename to cli/src/index.ts index f8db9eb2d..fb96dbec5 100644 --- a/cli/src/cli/index.ts +++ b/cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; -import { create } from '../create'; -import { setup } from '../setup'; +import { create } from './create'; +import { setup } from './setup'; declare let PACKAGE_VERSION: string; diff --git a/cli/src/models.ts b/cli/src/models.ts deleted file mode 100644 index fae847be6..000000000 --- a/cli/src/models.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type ApiResponse = SuccessResponse | ErrorResponse; - -export type SuccessResponse = { - status: 'success'; - data?: T; -}; - -export type ErrorResponse = { - status: 'error'; - error: { - message: string; - code: string; - }; -}; \ No newline at end of file diff --git a/cli/src/setup/constants.ts b/cli/src/setup/constants.ts deleted file mode 100644 index 4d889a2d7..000000000 --- a/cli/src/setup/constants.ts +++ /dev/null @@ -1,66 +0,0 @@ -export enum BUILD_TOOL_NAME { - NEXT = "next", - WEBPACK = "webpack", - CRA = "cra", - VITE = "vite", -} - -export enum DEPENDENCY_NAME { - NEXT = "next", - WEBPACK = "webpack", - CRA = "react-scripts", - VITE = "vite" -} - -export const NEXTJS_CONFIG_BASE_NAME = 'next.config'; -export const WEBPACK_CONFIG_BASE_NAME = 'webpack.config'; -export const VITEJS_CONFIG_BASE_NAME = 'vite.config'; - -export const CONFIG_FILE_PATTERN: Record = { - [BUILD_TOOL_NAME.NEXT]: `${NEXTJS_CONFIG_BASE_NAME}.*`, - [BUILD_TOOL_NAME.WEBPACK]: `${WEBPACK_CONFIG_BASE_NAME}.*`, - [BUILD_TOOL_NAME.VITE]: `${VITEJS_CONFIG_BASE_NAME}.*`, - [BUILD_TOOL_NAME.CRA]: '' -} - -export const PACKAGE_JSON = 'package.json'; - -export enum LOCK_FILE_NAME { - YARN = 'yarn.lock', - BUN = 'bun.lockb', - PNPM = 'pnpm-lock.yaml' -} - -export enum PACKAGE_MANAGER { - YARN = 'yarn', - NPM = 'npm', - PNPM = 'pnpm', - BUN = 'bun' -} - -export const ONLOOK_NEXTJS_PLUGIN = '@onlook/nextjs'; -export const ONLOOK_WEBPACK_PLUGIN = '@onlook/react'; -export const ONLOOK_BABEL_PLUGIN = '@onlook/babel-plugin-react'; - -export const NEXTJS_COMMON_FILES = ['pages', 'app', 'src/pages', 'src/app']; -export const CRA_COMMON_FILES = ['public', 'src']; - -export const CONFIG_OVERRIDES_FILE = 'config-overrides.js'; -export const BABELRC_FILE = '.babelrc'; - -export const JS_FILE_EXTENSION = '.js'; -export const MJS_FILE_EXTENSION = '.mjs'; -export const TS_FILE_EXTENSION = '.ts'; - -export const NEXT_DEPENDENCIES = [ONLOOK_NEXTJS_PLUGIN]; -export const CRA_DEPENDENCIES = [ONLOOK_BABEL_PLUGIN, 'customize-cra', 'react-app-rewired']; -export const VITE_DEPENDENCIES = [ONLOOK_BABEL_PLUGIN]; - -export const WEBPACK_DEPENDENCIES = [ - ONLOOK_BABEL_PLUGIN, - 'babel-loader', - '@babel/preset-react', - '@babel/core', - '@babel/preset-env', - 'webpack' -]; diff --git a/cli/src/setup/index.ts b/cli/src/setup/index.ts index 1440b6c70..f097b6cb9 100755 --- a/cli/src/setup/index.ts +++ b/cli/src/setup/index.ts @@ -1,28 +1,37 @@ -import { CRA_DEPENDENCIES } from './constants'; -import { ensureConfigOverrides, isCRAProject, modifyStartScript } from './cra'; -import { Framework } from './frameworks'; -import { installPackages } from './utils'; +import { type SetupCallback, setupProject, SetupStage } from '@onlook/utils'; +import ora from 'ora'; export const setup = async (): Promise => { - try { - for (const framework of Framework.getAll()) { - const updated = await framework.run(); - if (updated) { - return; - } + const targetPath = process.cwd(); + const spinner = ora('Initializing project...').start(); + + const progressCallback: SetupCallback = (stage: SetupStage, message: string) => { + switch (stage) { + case SetupStage.INSTALLING: + spinner.text = 'Cloning template...'; + break; + case SetupStage.CONFIGURING: + spinner.text = 'Installing dependencies...'; + break; + case SetupStage.COMPLETE: + spinner.succeed('Project created successfully!'); + console.log('\nTo get started:'); + console.log(` cd ${targetPath}`); + console.log(' npm run dev'); + break; + case SetupStage.ERROR: + spinner.fail(message); + break; + } } - if (await isCRAProject()) { - console.log('This is a create-react-app project.'); - await installPackages(CRA_DEPENDENCIES); - ensureConfigOverrides(); - modifyStartScript(); - return; + try { + await setupProject(targetPath, progressCallback); + } catch (error) { + spinner.fail('An error occurred'); + console.error('Error details:', error); + process.exit(1); } +}; - console.warn('Cannot determine the project framework.', '\nIf this is unexpected, see: https://github.com/onlook-dev/onlook/wiki/How-to-set-up-my-project%3F#do-it-manually'); - } catch (err) { - console.error(err); - } -}; diff --git a/cli/src/setup/next.ts b/cli/src/setup/next.ts deleted file mode 100644 index 922212219..000000000 --- a/cli/src/setup/next.ts +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env node -import * as fs from 'fs'; -import * as path from 'path'; - -import generate from '@babel/generator'; -import { parse } from '@babel/parser'; -import traverse from '@babel/traverse'; -import * as t from '@babel/types'; - -import { - checkVariableDeclarationExist, - exists, - genASTParserOptionsByFileExtension, - genImportDeclaration, - hasDependency, - isSupportFileExtension -} from './utils'; - -import { - BUILD_TOOL_NAME, - CONFIG_FILE_PATTERN, - DEPENDENCY_NAME, - NEXTJS_COMMON_FILES, - NEXTJS_CONFIG_BASE_NAME, - ONLOOK_NEXTJS_PLUGIN, -} from './constants'; - -export const isNextJsProject = async (): Promise => { - try { - const configPath = CONFIG_FILE_PATTERN[BUILD_TOOL_NAME.NEXT]; - - // Check if the configuration file exists - if (await exists(configPath)) { - return true; - } - - // Check if the dependency exists - if (!await hasDependency(DEPENDENCY_NAME.NEXT)) { - return false; - } - - // Check if one of the directories exists - const directoryExists = await Promise.all(NEXTJS_COMMON_FILES.map(exists)); - - return directoryExists.some(Boolean); - } catch (err) { - console.error(err); - return false; - } -}; - -export const modifyNextConfig = (configFileExtension: string): void => { - if (!isSupportFileExtension(configFileExtension)) { - console.error('Unsupported file extension'); - return; - } - - const configFileName = `${NEXTJS_CONFIG_BASE_NAME}${configFileExtension}`; - - // Define the path to next.config.* file - const configPath = path.resolve(process.cwd(), configFileName); - - if (!fs.existsSync(configPath)) { - console.error(`${configFileName} not found`); - return; - } - - console.log(`Adding ${ONLOOK_NEXTJS_PLUGIN} plugin into ${configFileName} file...`); - - // Read the existing next.config.* file - fs.readFile(configPath, 'utf8', (err, data) => { - if (err) { - console.error(`Error reading ${configPath}:`, err); - return; - } - - const astParserOption = genASTParserOptionsByFileExtension(configFileExtension); - - // Parse the file content to an AST - const ast = parse(data, astParserOption); - - let hasPathImport = false; - - // Traverse the AST to find the experimental.swcPlugins array - traverse(ast, { - VariableDeclarator(path) { - // check if path is imported in .js file - if (checkVariableDeclarationExist(path, 'path')) { - hasPathImport = true; - } - }, - ImportDeclaration(path) { - // check if path is imported in .mjs file - if (path.node.source.value === 'path') { - hasPathImport = true; - } - }, - ObjectExpression(path) { - const properties = path.node.properties; - let experimentalProperty: t.ObjectProperty | undefined; - - // Find the experimental property - properties.forEach(prop => { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'experimental' })) { - experimentalProperty = prop; - } - }); - - if (!experimentalProperty) { - // If experimental property is not found, create it - experimentalProperty = t.objectProperty( - t.identifier('experimental'), - t.objectExpression([]) - ); - properties.push(experimentalProperty); - } - - // Ensure experimental is an ObjectExpression - if (!t.isObjectExpression(experimentalProperty.value)) { - experimentalProperty.value = t.objectExpression([]); - } - - const experimentalProperties = experimentalProperty.value.properties; - let swcPluginsProperty: t.ObjectProperty | undefined; - - // Find the swcPlugins property - experimentalProperties.forEach(prop => { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'swcPlugins' })) { - swcPluginsProperty = prop; - } - }); - - if (!swcPluginsProperty) { - // If swcPlugins property is not found, create it - swcPluginsProperty = t.objectProperty( - t.identifier('swcPlugins'), - t.arrayExpression([]) - ); - experimentalProperties.push(swcPluginsProperty); - } - - // Ensure swcPlugins is an ArrayExpression - if (!t.isArrayExpression(swcPluginsProperty.value)) { - swcPluginsProperty.value = t.arrayExpression([]); - } - - // Add the new plugin configuration to swcPlugins array - const pluginConfig = t.arrayExpression([ - t.stringLiteral(ONLOOK_NEXTJS_PLUGIN), - t.objectExpression([ - t.objectProperty( - t.identifier('root'), - t.callExpression(t.memberExpression(t.identifier('path'), t.identifier('resolve')), [t.stringLiteral('.')]) - ) - ]) - ]); - - swcPluginsProperty.value.elements.push(pluginConfig); - - // Stop traversing after the modification - path.stop(); - } - }); - - // If 'path' is not imported, add the import statement - if (!hasPathImport) { - const importDeclaration = genImportDeclaration(configFileExtension, 'path'); - importDeclaration && ast.program.body.unshift(importDeclaration); - } - - // Generate the modified code from the AST - const updatedCode = generate(ast, {}, data).code; - - // Write the updated content back to next.config.* file - fs.writeFile(configPath, updatedCode, 'utf8', (err) => { - if (err) { - console.error(`Error writing ${configPath}:`, err); - return; - } - - console.log(`Successfully updated ${configPath}`); - }); - }); -}; diff --git a/cli/src/setup/utils.ts b/cli/src/setup/utils.ts deleted file mode 100644 index 7799af6df..000000000 --- a/cli/src/setup/utils.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { NodePath } from '@babel/traverse'; -import * as t from '@babel/types'; -import { execSync } from 'child_process'; -import * as glob from 'glob'; -import * as path from 'path'; -import { - JS_FILE_EXTENSION, - LOCK_FILE_NAME, - MJS_FILE_EXTENSION, - PACKAGE_JSON, - PACKAGE_MANAGER, - TS_FILE_EXTENSION -} from './constants'; - -export const exists = async (filePattern: string): Promise => { - try { - const pattern = path.resolve(process.cwd(), filePattern); - const files = getFileNamesByPattern(pattern); - return files.length > 0; - } catch (err) { - console.error(err); - return false; - } -}; - -export const getFileNamesByPattern = (pattern: string): string[] => glob.globSync(pattern); - -export const installPackages = async (packages: string[]): Promise => { - const packageManager = await getPackageManager(); - const command = packageManager === PACKAGE_MANAGER.YARN ? 'yarn add -D' : `${packageManager} install -D`; - - console.log("Package manager found:", packageManager) - console.log("\n$", `${command} ${packages.join(' ')}`) - - execSync(`${command} ${packages.join(' ')}`, { stdio: 'inherit' }); -}; - -export const getPackageManager = async (): Promise => { - try { - if (await exists(LOCK_FILE_NAME.YARN)) { - return PACKAGE_MANAGER.YARN; - } - if (await exists(LOCK_FILE_NAME.PNPM)) { - return PACKAGE_MANAGER.PNPM; - } - if (await exists(LOCK_FILE_NAME.BUN)) { - return PACKAGE_MANAGER.BUN; - } - return PACKAGE_MANAGER.NPM; - } catch (e) { - console.error("Error determining package manager, using npm by default", e) - return PACKAGE_MANAGER.NPM - } - -}; - -export const hasDependency = async (dependencyName: string): Promise => { - const packageJsonPath = path.resolve(PACKAGE_JSON); - if (await exists(packageJsonPath)) { - const packageJson = require(packageJsonPath); - return ( - (packageJson.dependencies && packageJson.dependencies[dependencyName]) || - (packageJson.devDependencies && packageJson.devDependencies[dependencyName]) - ); - } - return false; -}; - -export const getFileExtensionByPattern = async (dir: string, filePattern: string): Promise => { - const fullDirPattern = path.resolve(dir, filePattern); - const files = await getFileNamesByPattern(fullDirPattern); - - if (files.length > 0) { - return path.extname(files[0]); - } - - return null; -}; - -export const genASTParserOptionsByFileExtension = (fileExtension: string, sourceType: string = 'module'): object => { - switch (fileExtension) { - case JS_FILE_EXTENSION: - return { - sourceType: sourceType - }; - case MJS_FILE_EXTENSION: - return { - sourceType: sourceType, - plugins: ['jsx'] - }; - case TS_FILE_EXTENSION: - return { - sourceType: sourceType, - plugins: ['typescript'] - }; - default: - return {}; - } -}; - -export const genImportDeclaration = (fileExtension: string, dependency: string): t.VariableDeclaration | t.ImportDeclaration | null => { - switch (fileExtension) { - case JS_FILE_EXTENSION: - return t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(dependency), - t.callExpression(t.identifier('require'), [t.stringLiteral(dependency)]) - ) - ]); - case MJS_FILE_EXTENSION: - return t.importDeclaration( - [t.importDefaultSpecifier(t.identifier(dependency))], - t.stringLiteral(dependency) - ); - default: - return null; - } -}; - -export const checkVariableDeclarationExist = (path: NodePath, dependency: string): boolean => { - return t.isIdentifier(path.node.id, { name: dependency }) && - t.isCallExpression(path.node.init) && - (path.node.init.callee as t.V8IntrinsicIdentifier).name === 'require' && - (path.node.init.arguments[0] as any).value === dependency; -}; - -export const isSupportFileExtension = (fileExtension: string): boolean => { - return [JS_FILE_EXTENSION, MJS_FILE_EXTENSION].indexOf(fileExtension) !== -1; -}; - -export const isViteProjectSupportFileExtension = (fileExtension: string): boolean => { - return [JS_FILE_EXTENSION, TS_FILE_EXTENSION].indexOf(fileExtension) !== -1; -}; diff --git a/cli/tests/program.test.ts b/cli/tests/program.test.ts index 948260a5d..2025d2558 100644 --- a/cli/tests/program.test.ts +++ b/cli/tests/program.test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, jest, mock, test } from "bun:test"; -import { createProgram } from "../src/cli"; +import { createProgram } from "../src"; import { setup } from "../src/setup"; const originalConsoleLog = console.log; diff --git a/docs/bun.lockb b/docs/bun.lockb index 024575ca7..f6656f303 100755 Binary files a/docs/bun.lockb and b/docs/bun.lockb differ diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 40252a559..eb7566655 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -1,10 +1,10 @@ import path from "path"; - const nextConfig = { - reactStrictMode: true, - experimental: { - swcPlugins: [["@onlook/nextjs", { root: path.resolve(".") }]], - }, -} - -export default nextConfig + reactStrictMode: true, + experimental: { + swcPlugins: [["@onlook/nextjs", { + root: path.resolve(".") + }]] + } +}; +export default nextConfig; \ No newline at end of file diff --git a/docs/package.json b/docs/package.json index c6b46d54c..2e63dcb63 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,44 +1,44 @@ { - "name": "@onlook/docs", - "version": "0.0.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "format": "prettier ./src --write", - "format:check": "prettier ./src --check" - }, - "dependencies": { - "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-slot": "^1.0.2", - "@t3-oss/env-nextjs": "^0.9.2", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.1", - "lucide-react": "^0.330.0", - "next": "14.2.10", - "next-entree": ".", - "next-themes": "^0.2.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "tailwind-merge": "^2.3.0", - "tailwindcss-animate": "^1.0.7", - "zod": "^3.23.4" - }, - "devDependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.2.1", - "@onlook/nextjs": "latest", - "@types/node": "^20.12.7", - "@types/react": "^18.3.1", - "@types/react-dom": "^18.3.0", - "autoprefixer": "^10.4.19", - "eslint": "^8.57.0", - "eslint-config-next": "14.1.0", - "postcss": "^8.4.38", - "prettier": "^3.2.5", - "prettier-plugin-tailwindcss": "^0.5.14", - "tailwindcss": "^3.4.3", - "typescript": "^5.4.5" - } + "name": "@onlook/docs", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "format": "prettier ./src --write", + "format:check": "prettier ./src --check" + }, + "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-slot": "^1.0.2", + "@t3-oss/env-nextjs": "^0.9.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.330.0", + "next": "14.2.10", + "next-entree": ".", + "next-themes": "^0.2.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.4" + }, + "devDependencies": { + "@ianvs/prettier-plugin-sort-imports": "^4.2.1", + "@onlook/nextjs": "^2.1.1", + "@types/node": "^20.12.7", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-config-next": "14.1.0", + "postcss": "^8.4.38", + "prettier": "^3.2.5", + "prettier-plugin-tailwindcss": "^0.5.14", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.5" + } } \ No newline at end of file diff --git a/package.json b/package.json index c4122bd18..edd0ce543 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,25 @@ { - "name": "@onlook/studio", - "version": "0.0.0", - "description": "Onlook Studio", - "homepage": "https://onlook.dev", - "main": "dist-electron/main/index.js", - "license": "Apache-2.0", - "author": { - "name": "Onlook", - "email": "contact@onlook.dev" - }, - "scripts": { - "prepare": "husky" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/onlook-dev/onlook.git" - }, - "bugs": { - "url": "https://github.com/onlook-dev/onlook/issues" - }, - "devDependencies": { - "husky": "^9.0.11" - } + "name": "@onlook/studio", + "version": "0.0.0", + "description": "Onlook Studio", + "homepage": "https://onlook.dev", + "main": "dist-electron/main/index.js", + "license": "Apache-2.0", + "author": { + "name": "Onlook", + "email": "contact@onlook.dev" + }, + "scripts": { + "prepare": "husky" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/onlook-dev/onlook.git" + }, + "bugs": { + "url": "https://github.com/onlook-dev/onlook/issues" + }, + "devDependencies": { + "husky": "^9.0.11" + } } \ No newline at end of file diff --git a/utils/.gitignore b/utils/.gitignore new file mode 100644 index 000000000..03cf4ff52 --- /dev/null +++ b/utils/.gitignore @@ -0,0 +1,178 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Bundle +build \ No newline at end of file diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 000000000..44cc9d437 --- /dev/null +++ b/utils/README.md @@ -0,0 +1,7 @@ +# Onlook Utils + +A shared utility package for Onlook. Includes file system functionalities used in both the Onlook app and the CLI + +- [X] Create new project +- [X] Setup existing project +- [X] Verify Onlook installation \ No newline at end of file diff --git a/utils/bun.lockb b/utils/bun.lockb new file mode 100755 index 000000000..9677c60a2 Binary files /dev/null and b/utils/bun.lockb differ diff --git a/utils/package.json b/utils/package.json new file mode 100644 index 000000000..78ff99772 --- /dev/null +++ b/utils/package.json @@ -0,0 +1,43 @@ +{ + "name": "@onlook/utils", + "description": "A shared utility library for Onlook", + "version": "0.0.3", + "type": "commonjs", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "directories": { + "test": "tests" + }, + "scripts": { + "dev": "npm run build -- --watch", + "build": "tsup src/index.ts --format esm,cjs --outDir dist --dts", + "test": "bun test" + }, + "keywords": [ + "onlook", + "utils" + ], + "author": { + "name": "Onlook", + "email": "contact@onlook.dev" + }, + "license": "Apache-2.0", + "homepage": "https://onlook.dev", + "devDependencies": { + "@types/babel__generator": "^7.6.8", + "@types/babel__traverse": "^7.20.6", + "@types/bun": "latest", + "@types/degit": "^2.8.6", + "tslib": "^2.6.3", + "tsup": "^8.3.0", + "typescript": "^5.0.0" + }, + "dependencies": { + "@babel/generator": "^7.14.5", + "@babel/parser": "^7.14.3", + "@babel/traverse": "^7.14.5", + "@babel/types": "^7.14.5", + "degit": "^2.8.4" + } +} \ No newline at end of file diff --git a/utils/src/constants.ts b/utils/src/constants.ts new file mode 100644 index 000000000..694ba0a4f --- /dev/null +++ b/utils/src/constants.ts @@ -0,0 +1,72 @@ +export enum BUILD_TOOL_NAME { + NEXT = "next", + WEBPACK = "webpack", + CRA = "cra", + VITE = "vite", +} + +export enum DEPENDENCY_NAME { + NEXT = "next", + WEBPACK = "webpack", + CRA = "react-scripts", + VITE = "vite" +} + +export enum CONFIG_BASE_NAME { + NEXTJS = 'next.config', + WEBPACK = 'webpack.config', + VITEJS = 'vite.config' +} + +export const CONFIG_FILE_PATTERN: Record = { + [BUILD_TOOL_NAME.NEXT]: `${CONFIG_BASE_NAME.NEXTJS}.*`, + [BUILD_TOOL_NAME.WEBPACK]: `${CONFIG_BASE_NAME.WEBPACK}.*`, + [BUILD_TOOL_NAME.VITE]: `${CONFIG_BASE_NAME.VITEJS}.*`, + [BUILD_TOOL_NAME.CRA]: '' +} + +export const PACKAGE_JSON = 'package.json'; + +export enum LOCK_FILE_NAME { + YARN = 'yarn.lock', + BUN = 'bun.lockb', + PNPM = 'pnpm-lock.yaml' +} + +export enum PACKAGE_MANAGER { + YARN = 'yarn', + NPM = 'npm', + PNPM = 'pnpm', + BUN = 'bun' +} + +export enum ONLOOK_PLUGIN { + NEXTJS = '@onlook/nextjs', + WEBPACK = '@onlook/react', + BABEL = '@onlook/babel-plugin-react' +} + +export const NEXTJS_COMMON_FILES = ['pages', 'app', 'src/pages', 'src/app']; +export const CRA_COMMON_FILES = ['public', 'src']; + +export const CONFIG_OVERRIDES_FILE = 'config-overrides.js'; +export const BABELRC_FILE = '.babelrc'; + +export enum FILE_EXTENSION { + JS = '.js', + MJS = '.mjs', + TS = '.ts', +} + +export const NEXT_DEPENDENCIES = [ONLOOK_PLUGIN.NEXTJS]; +export const CRA_DEPENDENCIES = [ONLOOK_PLUGIN.BABEL, 'customize-cra', 'react-app-rewired']; +export const VITE_DEPENDENCIES = [ONLOOK_PLUGIN.BABEL]; + +export const WEBPACK_DEPENDENCIES = [ + ONLOOK_PLUGIN.BABEL, + 'babel-loader', + '@babel/preset-react', + '@babel/core', + '@babel/preset-env', + 'webpack' +]; diff --git a/utils/src/create/index.ts b/utils/src/create/index.ts new file mode 100644 index 000000000..99359e7aa --- /dev/null +++ b/utils/src/create/index.ts @@ -0,0 +1,47 @@ +import { exec } from 'child_process'; +import degit from 'degit'; +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; +import { CreateStage, type CreateCallback } from '..'; + +const NEXT_TEMPLATE_REPO = 'onlook-dev/starter'; +const execAsync = promisify(exec); + +export async function createProject( + projectName: string, + targetPath: string, + onProgress: CreateCallback +): Promise { + const fullPath = path.join(targetPath, projectName); + + // Check if the directory already exists + if (fs.existsSync(fullPath)) { + throw new Error(`Directory ${fullPath} already exists.`); + } + + try { + // Clone the template using degit + onProgress(CreateStage.CLONING, `Cloning template...`); + const emitter = degit(NEXT_TEMPLATE_REPO, { + cache: false, + force: true, + verbose: true, + }); + + await emitter.clone(fullPath); + + // Change to the project directory + process.chdir(fullPath); + + // Install dependencies + onProgress(CreateStage.INSTALLING, 'Installing dependencies...'); + await execAsync('npm install'); + + onProgress(CreateStage.COMPLETE, 'Project created successfully!'); + } catch (error) { + onProgress(CreateStage.ERROR, `Project creation failed: ${error}`); + throw error; + } +} + diff --git a/cli/src/setup/cra.ts b/utils/src/frameworks/cra.ts similarity index 93% rename from cli/src/setup/cra.ts rename to utils/src/frameworks/cra.ts index 3955f52ad..2fb76b964 100644 --- a/cli/src/setup/cra.ts +++ b/utils/src/frameworks/cra.ts @@ -1,5 +1,5 @@ -import { CONFIG_OVERRIDES_FILE, CRA_COMMON_FILES, DEPENDENCY_NAME, JS_FILE_EXTENSION, ONLOOK_WEBPACK_PLUGIN, PACKAGE_JSON } from "./constants"; -import { exists, genASTParserOptionsByFileExtension, hasDependency } from "./utils"; +import { CONFIG_OVERRIDES_FILE, CRA_COMMON_FILES, DEPENDENCY_NAME, FILE_EXTENSION, ONLOOK_PLUGIN, PACKAGE_JSON } from "../constants"; +import { exists, genASTParserOptionsByFileExtension, hasDependency } from "../utils"; import generate from '@babel/generator'; import { parse } from '@babel/parser'; @@ -19,11 +19,16 @@ const defaultContent = ` module.exports = override( ...addBabelPlugins( - '${ONLOOK_WEBPACK_PLUGIN}' + '${ONLOOK_PLUGIN.WEBPACK}' ) ); `; +export const modifyCRAConfig = (): void => { + ensureConfigOverrides(); + modifyStartScript(); +} + export const ensureConfigOverrides = (): void => { // Handle the case when the file does not exist if (!fs.existsSync(configOverridesPath)) { @@ -39,7 +44,7 @@ export const ensureConfigOverrides = (): void => { return; } // Read the existing file - const ast = parse(fileContent, genASTParserOptionsByFileExtension(JS_FILE_EXTENSION)); + const ast = parse(fileContent, genASTParserOptionsByFileExtension(FILE_EXTENSION.JS)); let hasCustomizeCraImport = false; let hasOnlookReactPlugin = false; @@ -63,7 +68,7 @@ export const ensureConfigOverrides = (): void => { path.node.arguments.forEach(arg => { if (t.isSpreadElement(arg) && t.isCallExpression(arg.argument) && t.isIdentifier(arg.argument.callee, { name: 'addBabelPlugins' }) && - arg.argument.arguments.some(pluginArg => t.isStringLiteral(pluginArg, { value: ONLOOK_WEBPACK_PLUGIN }))) { + arg.argument.arguments.some(pluginArg => t.isStringLiteral(pluginArg, { value: ONLOOK_PLUGIN.WEBPACK }))) { hasOnlookReactPlugin = true; } }); @@ -93,7 +98,7 @@ export const ensureConfigOverrides = (): void => { // @ts-ignore path.node.right.arguments.push( t.spreadElement(t.callExpression(t.identifier('addBabelPlugins'), [ - t.stringLiteral(ONLOOK_WEBPACK_PLUGIN) + t.stringLiteral(ONLOOK_PLUGIN.WEBPACK) ])) ); } diff --git a/cli/src/setup/frameworks.ts b/utils/src/frameworks/frameworks.ts similarity index 72% rename from cli/src/setup/frameworks.ts rename to utils/src/frameworks/frameworks.ts index 8885b5993..b9715429a 100644 --- a/cli/src/setup/frameworks.ts +++ b/utils/src/frameworks/frameworks.ts @@ -1,14 +1,16 @@ -import { BUILD_TOOL_NAME, CONFIG_FILE_PATTERN, NEXT_DEPENDENCIES, VITE_DEPENDENCIES, WEBPACK_DEPENDENCIES } from "./constants"; +import { BUILD_TOOL_NAME, CONFIG_FILE_PATTERN, CRA_DEPENDENCIES, NEXT_DEPENDENCIES, VITE_DEPENDENCIES, WEBPACK_DEPENDENCIES } from "../constants"; +import { getFileExtensionByPattern, installPackages } from "../utils"; +import { isCRAProject, modifyCRAConfig } from "./cra"; import { isNextJsProject, modifyNextConfig } from "./next"; -import { getFileExtensionByPattern, installPackages } from "./utils"; import { isViteJsProject, modifyViteConfig } from "./vite"; import { isWebpackProject, modifyWebpackConfig } from "./webpack"; + export class Framework { static readonly NEXT = new Framework("Next.js", isNextJsProject, modifyNextConfig, NEXT_DEPENDENCIES, BUILD_TOOL_NAME.NEXT); static readonly VITE = new Framework("Vite", isViteJsProject, modifyViteConfig, VITE_DEPENDENCIES, BUILD_TOOL_NAME.VITE); static readonly WEBPACK = new Framework("Webpack", isWebpackProject, modifyWebpackConfig, WEBPACK_DEPENDENCIES, BUILD_TOOL_NAME.WEBPACK); - // static readonly CRA = new Framework("Create React App", isCRAProject, modifyCRAConfig, CRA_DEPENDENCIES, BUILD_TOOL_NAME.CRA); + static readonly CRA = new Framework("Create React App", isCRAProject, modifyCRAConfig, CRA_DEPENDENCIES, BUILD_TOOL_NAME.CRA); private constructor( public readonly name: string, @@ -18,7 +20,9 @@ export class Framework { public readonly buildToolName: BUILD_TOOL_NAME ) { } - run = async (): Promise => { + setup = async (): Promise => { + console.log(process.cwd()); + console.log(`Checking for ${this.name} configuration...`); if (await this.identify()) { console.log(`This is a ${this.name} project.`); @@ -38,7 +42,7 @@ export class Framework { this.NEXT, this.VITE, this.WEBPACK, - // this.CRA, + this.CRA, ]; } } diff --git a/utils/src/frameworks/index.ts b/utils/src/frameworks/index.ts new file mode 100644 index 000000000..9443518c5 --- /dev/null +++ b/utils/src/frameworks/index.ts @@ -0,0 +1,48 @@ +import { SetupStage, type SetupCallback } from ".."; +import { BUILD_TOOL_NAME, CONFIG_FILE_PATTERN, CRA_DEPENDENCIES, NEXT_DEPENDENCIES, VITE_DEPENDENCIES, WEBPACK_DEPENDENCIES } from "../constants"; +import { getFileExtensionByPattern, installPackages } from "../utils"; +import { isCRAProject, modifyCRAConfig } from "./cra"; +import { isNextJsProject, modifyNextConfig } from "./next"; +import { isViteJsProject, modifyViteConfig } from "./vite"; +import { isWebpackProject, modifyWebpackConfig } from "./webpack"; + +export class Framework { + static readonly NEXT = new Framework("Next.js", isNextJsProject, modifyNextConfig, NEXT_DEPENDENCIES, BUILD_TOOL_NAME.NEXT); + static readonly VITE = new Framework("Vite", isViteJsProject, modifyViteConfig, VITE_DEPENDENCIES, BUILD_TOOL_NAME.VITE); + static readonly WEBPACK = new Framework("Webpack", isWebpackProject, modifyWebpackConfig, WEBPACK_DEPENDENCIES, BUILD_TOOL_NAME.WEBPACK); + static readonly CRA = new Framework("Create React App", isCRAProject, modifyCRAConfig, CRA_DEPENDENCIES, BUILD_TOOL_NAME.CRA); + + private constructor( + public readonly name: string, + public readonly identify: () => Promise, + public readonly updateConfig: (configFileExtension: string) => void, + public readonly dependencies: string[], + public readonly buildToolName: BUILD_TOOL_NAME + ) { } + + setup = async (callback: SetupCallback): Promise => { + + if (await this.identify()) { + callback(SetupStage.INSTALLING, `Installing required packages for ${this.name}...`); + + await installPackages(this.dependencies); + + callback(SetupStage.CONFIGURING, `Applying ${this.name} configuration...`); + const configFileExtension = await getFileExtensionByPattern(process.cwd(), CONFIG_FILE_PATTERN[this.buildToolName]); + if (configFileExtension) { + await this.updateConfig(configFileExtension); + } + return true; + } + return false; + } + + static getAll(): Framework[] { + return [ + this.NEXT, + this.VITE, + this.WEBPACK, + this.CRA, + ]; + } +} diff --git a/utils/src/frameworks/next.ts b/utils/src/frameworks/next.ts new file mode 100644 index 000000000..d3ba6659d --- /dev/null +++ b/utils/src/frameworks/next.ts @@ -0,0 +1,184 @@ +#!/usr/bin/env node +import * as fs from 'fs'; +import * as path from 'path'; + +import generate from '@babel/generator'; +import { parse } from '@babel/parser'; +import traverse from '@babel/traverse'; +import * as t from '@babel/types'; + +import { + checkVariableDeclarationExist, + exists, + genASTParserOptionsByFileExtension, + genImportDeclaration, + hasDependency, + isSupportFileExtension +} from '../utils'; + +import { + BUILD_TOOL_NAME, + CONFIG_BASE_NAME, + CONFIG_FILE_PATTERN, + DEPENDENCY_NAME, + NEXTJS_COMMON_FILES, + ONLOOK_PLUGIN, +} from '../constants'; + +export const isNextJsProject = async (): Promise => { + try { + const configPath = CONFIG_FILE_PATTERN[BUILD_TOOL_NAME.NEXT]; + + // Check if the configuration file exists + if (await exists(configPath)) { + return true; + } + + // Check if the dependency exists + if (!await hasDependency(DEPENDENCY_NAME.NEXT)) { + return false; + } + + // Check if one of the directories exists + const directoryExists = await Promise.all(NEXTJS_COMMON_FILES.map(exists)); + + return directoryExists.some(Boolean); + } catch (err) { + console.error(err); + return false; + } +}; + +export const modifyNextConfig = (configFileExtension: string): void => { + if (!isSupportFileExtension(configFileExtension)) { + console.error('Unsupported file extension'); + return; + } + + const configFileName = `${CONFIG_BASE_NAME.NEXTJS}${configFileExtension}`; + + // Define the path to next.config.* file + const configPath = path.resolve(process.cwd(), configFileName); + + if (!fs.existsSync(configPath)) { + console.error(`${configFileName} not found`); + return; + } + + console.log(`Adding ${ONLOOK_PLUGIN.NEXTJS} plugin into ${configFileName} file...`); + + // Read the existing next.config.* file + fs.readFile(configPath, 'utf8', (err, data) => { + if (err) { + console.error(`Error reading ${configPath}:`, err); + return; + } + + const astParserOption = genASTParserOptionsByFileExtension(configFileExtension); + + // Parse the file content to an AST + const ast = parse(data, astParserOption); + + let hasPathImport = false; + + // Traverse the AST to find the experimental.swcPlugins array + traverse(ast, { + VariableDeclarator(path) { + // check if path is imported in .js file + if (checkVariableDeclarationExist(path, 'path')) { + hasPathImport = true; + } + }, + ImportDeclaration(path) { + // check if path is imported in .mjs file + if (path.node.source.value === 'path') { + hasPathImport = true; + } + }, + ObjectExpression(path) { + const properties = path.node.properties; + let experimentalProperty: t.ObjectProperty | undefined; + + // Find the experimental property + properties.forEach(prop => { + if (t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'experimental' })) { + experimentalProperty = prop; + } + }); + + if (!experimentalProperty) { + // If experimental property is not found, create it + experimentalProperty = t.objectProperty( + t.identifier('experimental'), + t.objectExpression([]) + ); + properties.push(experimentalProperty); + } + + // Ensure experimental is an ObjectExpression + if (!t.isObjectExpression(experimentalProperty.value)) { + experimentalProperty.value = t.objectExpression([]); + } + + const experimentalProperties = experimentalProperty.value.properties; + let swcPluginsProperty: t.ObjectProperty | undefined; + + // Find the swcPlugins property + experimentalProperties.forEach(prop => { + if (t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'swcPlugins' })) { + swcPluginsProperty = prop; + } + }); + + if (!swcPluginsProperty) { + // If swcPlugins property is not found, create it + swcPluginsProperty = t.objectProperty( + t.identifier('swcPlugins'), + t.arrayExpression([]) + ); + experimentalProperties.push(swcPluginsProperty); + } + + // Ensure swcPlugins is an ArrayExpression + if (!t.isArrayExpression(swcPluginsProperty.value)) { + swcPluginsProperty.value = t.arrayExpression([]); + } + + // Add the new plugin configuration to swcPlugins array + const pluginConfig = t.arrayExpression([ + t.stringLiteral(ONLOOK_PLUGIN.NEXTJS), + t.objectExpression([ + t.objectProperty( + t.identifier('root'), + t.callExpression(t.memberExpression(t.identifier('path'), t.identifier('resolve')), [t.stringLiteral('.')]) + ) + ]) + ]); + + swcPluginsProperty.value.elements.push(pluginConfig); + + // Stop traversing after the modification + path.stop(); + } + }); + + // If 'path' is not imported, add the import statement + if (!hasPathImport) { + const importDeclaration = genImportDeclaration(configFileExtension, 'path'); + importDeclaration && ast.program.body.unshift(importDeclaration); + } + + // Generate the modified code from the AST + const updatedCode = generate(ast, {}, data).code; + + // Write the updated content back to next.config.* file + fs.writeFile(configPath, updatedCode, 'utf8', (err) => { + if (err) { + console.error(`Error writing ${configPath}:`, err); + return; + } + + console.log(`Successfully updated ${configPath}`); + }); + }); +}; diff --git a/cli/src/setup/vite.ts b/utils/src/frameworks/vite.ts similarity index 95% rename from cli/src/setup/vite.ts rename to utils/src/frameworks/vite.ts index b6ca41d1b..f102ca8b8 100644 --- a/cli/src/setup/vite.ts +++ b/utils/src/frameworks/vite.ts @@ -7,17 +7,17 @@ import * as path from 'path'; import { BUILD_TOOL_NAME, + CONFIG_BASE_NAME, CONFIG_FILE_PATTERN, DEPENDENCY_NAME, - ONLOOK_BABEL_PLUGIN, - VITEJS_CONFIG_BASE_NAME -} from "./constants"; + ONLOOK_PLUGIN, +} from "../constants"; import { exists, genASTParserOptionsByFileExtension, hasDependency, isViteProjectSupportFileExtension -} from "./utils"; +} from "../utils"; // Function to check if a plugin is already in the array function hasPlugin(pluginsArray: t.Expression[], pluginName: string): boolean { @@ -56,7 +56,7 @@ export const modifyViteConfig = (configFileExtension: string): void => { return; } - const configFileName = `${VITEJS_CONFIG_BASE_NAME}${configFileExtension}`; + const configFileName = `${CONFIG_BASE_NAME.VITEJS}${configFileExtension}`; const configPath = path.resolve(process.cwd(), configFileName); if (!fs.existsSync(configPath)) { @@ -127,7 +127,7 @@ export const modifyViteConfig = (configFileExtension: string): void => { t.objectExpression([ t.objectProperty( t.identifier('plugins'), - t.arrayExpression([t.stringLiteral(ONLOOK_BABEL_PLUGIN)]) + t.arrayExpression([t.stringLiteral(ONLOOK_PLUGIN.BABEL)]) ) ]) ) @@ -148,7 +148,7 @@ export const modifyViteConfig = (configFileExtension: string): void => { t.objectExpression([ t.objectProperty( t.identifier('plugins'), - t.arrayExpression([t.stringLiteral(ONLOOK_BABEL_PLUGIN)]) + t.arrayExpression([t.stringLiteral(ONLOOK_PLUGIN.BABEL)]) ) ]) ) @@ -170,7 +170,7 @@ export const modifyViteConfig = (configFileExtension: string): void => { t.objectExpression([ t.objectProperty( t.identifier('plugins'), - t.arrayExpression([t.stringLiteral(ONLOOK_BABEL_PLUGIN)]) + t.arrayExpression([t.stringLiteral(ONLOOK_PLUGIN.BABEL)]) ) ]) ); @@ -185,14 +185,14 @@ export const modifyViteConfig = (configFileExtension: string): void => { if (!pluginsProp) { pluginsProp = t.objectProperty( t.identifier('plugins'), - t.arrayExpression([t.stringLiteral(ONLOOK_BABEL_PLUGIN)]) + t.arrayExpression([t.stringLiteral(ONLOOK_PLUGIN.BABEL)]) ); babelProp.value.properties.push(pluginsProp); reactPluginAdded = true; onlookBabelPluginAdded = true; } else if (t.isArrayExpression(pluginsProp.value)) { - if (!hasPlugin(pluginsProp.value.elements as t.Expression[], ONLOOK_BABEL_PLUGIN)) { - pluginsProp.value.elements.push(t.stringLiteral(ONLOOK_BABEL_PLUGIN)); + if (!hasPlugin(pluginsProp.value.elements as t.Expression[], ONLOOK_PLUGIN.BABEL)) { + pluginsProp.value.elements.push(t.stringLiteral(ONLOOK_PLUGIN.BABEL)); reactPluginAdded = true; onlookBabelPluginAdded = true; } @@ -213,7 +213,7 @@ export const modifyViteConfig = (configFileExtension: string): void => { console.log(`React plugin added to ${configFileName}`); } if (onlookBabelPluginAdded) { - console.log(`${ONLOOK_BABEL_PLUGIN} plugin added to ${configFileName}`); + console.log(`${ONLOOK_PLUGIN.BABEL} plugin added to ${configFileName}`); } if (reactImportAdded) { console.log(`React import added to ${configFileName}`); diff --git a/cli/src/setup/webpack.ts b/utils/src/frameworks/webpack.ts similarity index 95% rename from cli/src/setup/webpack.ts rename to utils/src/frameworks/webpack.ts index be6af8662..e169f6cec 100644 --- a/cli/src/setup/webpack.ts +++ b/utils/src/frameworks/webpack.ts @@ -8,13 +8,13 @@ import * as path from 'path'; import { BABELRC_FILE, BUILD_TOOL_NAME, + CONFIG_BASE_NAME, CONFIG_FILE_PATTERN, DEPENDENCY_NAME, - ONLOOK_WEBPACK_PLUGIN, - WEBPACK_CONFIG_BASE_NAME -} from "./constants"; + ONLOOK_PLUGIN, +} from "../constants"; -import { exists, hasDependency, isSupportFileExtension } from "./utils"; +import { exists, hasDependency, isSupportFileExtension } from "../utils"; export const isWebpackProject = async (): Promise => { try { @@ -60,7 +60,7 @@ export function modifyWebpackConfig(configFileExtension: string): void { return; } - const configFileName = `${WEBPACK_CONFIG_BASE_NAME}${configFileExtension}`; + const configFileName = `${CONFIG_BASE_NAME.WEBPACK}${configFileExtension}`; // Define the path to webpack.config.* file const configPath = path.resolve(process.cwd(), configFileName); @@ -157,9 +157,9 @@ export const modifyBabelrc = (): void => { } // Check if "@onlook/react" is already in the plugins array - if (!babelrcContent.plugins.includes(ONLOOK_WEBPACK_PLUGIN)) { + if (!babelrcContent.plugins.includes(ONLOOK_PLUGIN.WEBPACK)) { // Add "@onlook/react" to the plugins array - babelrcContent.plugins.push(ONLOOK_WEBPACK_PLUGIN); + babelrcContent.plugins.push(ONLOOK_PLUGIN.WEBPACK); } // Write the updated content back to the .babelrc file diff --git a/utils/src/index.ts b/utils/src/index.ts new file mode 100644 index 000000000..c23aba96f --- /dev/null +++ b/utils/src/index.ts @@ -0,0 +1,28 @@ +export { createProject } from './create'; +export { setupProject } from './setup'; +export { verifyProject } from './verify'; + +export enum CreateStage { + CLONING = 'cloning', + INSTALLING = 'installing', + COMPLETE = 'complete', + ERROR = 'error' +} + +export enum VerifyStage { + CHECKING = 'checking', + NOT_INSTALLED = 'not_installed', + INSTALLED = 'installed', + ERROR = 'error' +} + +export enum SetupStage { + INSTALLING = 'installing', + CONFIGURING = 'configuring', + COMPLETE = 'complete', + ERROR = 'error' +} + +export type CreateCallback = (stage: CreateStage, message: string) => void; +export type VerifyCallback = (stage: VerifyStage, message: string) => void; +export type SetupCallback = (stage: SetupStage, message: string) => void; diff --git a/utils/src/setup/index.ts b/utils/src/setup/index.ts new file mode 100755 index 000000000..62e9d565e --- /dev/null +++ b/utils/src/setup/index.ts @@ -0,0 +1,23 @@ +import { SetupStage, type SetupCallback } from '..'; +import { Framework } from '../frameworks'; + +export const setupProject = async (targetPath: string, onProgress: SetupCallback): Promise => { + try { + process.chdir(targetPath); + onProgress(SetupStage.INSTALLING, 'Installing required packages...'); + + for (const framework of Framework.getAll()) { + onProgress(SetupStage.INSTALLING, 'Checking for' + framework.name + ' configuration...'); + const updated = await framework.setup(onProgress); + if (updated) { + onProgress(SetupStage.COMPLETE, 'Project setup complete.'); + return; + } + } + console.error('Cannot determine the project framework.', '\nIf this is unexpected, see: https://github.com/onlook-dev/onlook/wiki/How-to-set-up-my-project%3F#do-it-manually'); + onProgress(SetupStage.ERROR, 'Project setup failed.'); + } catch (err) { + console.error(err); + onProgress(SetupStage.ERROR, 'An error occurred.'); + } +}; diff --git a/utils/src/utils.ts b/utils/src/utils.ts new file mode 100644 index 000000000..3c1d1a2f2 --- /dev/null +++ b/utils/src/utils.ts @@ -0,0 +1,134 @@ +import { NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as glob from 'glob'; +import * as path from 'path'; +import { + FILE_EXTENSION, + LOCK_FILE_NAME, + PACKAGE_JSON, + PACKAGE_MANAGER, +} from './constants'; + +export const exists = async (filePattern: string): Promise => { + try { + const pattern = path.resolve(process.cwd(), filePattern); + const files = getFileNamesByPattern(pattern); + return files.length > 0; + } catch (err) { + console.error(err); + return false; + } +}; + +export const getFileNamesByPattern = (pattern: string): string[] => glob.globSync(pattern); + +export const installPackages = async (packages: string[]): Promise => { + const packageManager = await getPackageManager(); + const command = packageManager === PACKAGE_MANAGER.YARN ? 'yarn add -D' : `${packageManager} install -D`; + + console.log("Package manager found:", packageManager) + console.log("\n$", `${command} ${packages.join(' ')}`) + + execSync(`${command} ${packages.join(' ')}`, { stdio: 'inherit' }); +}; + +export const getPackageManager = async (): Promise => { + try { + if (await exists(LOCK_FILE_NAME.YARN)) { + return PACKAGE_MANAGER.YARN; + } + if (await exists(LOCK_FILE_NAME.PNPM)) { + return PACKAGE_MANAGER.PNPM; + } + if (await exists(LOCK_FILE_NAME.BUN)) { + return PACKAGE_MANAGER.BUN; + } + return PACKAGE_MANAGER.NPM; + } catch (e) { + console.error("Error determining package manager, using npm by default", e) + return PACKAGE_MANAGER.NPM + } + +}; + +export const hasDependency = async (dependencyName: string, targetPath?: string): Promise => { + const packageJsonPath = targetPath ? path.resolve(targetPath, PACKAGE_JSON) : path.resolve(PACKAGE_JSON); + + if (await exists(packageJsonPath)) { + const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8'); + const packageJson = JSON.parse(packageJsonContent); + return ( + (packageJson.dependencies && dependencyName in packageJson.dependencies) || + (packageJson.devDependencies && dependencyName in packageJson.devDependencies) + ); + } + return false; +}; + +export const getFileExtensionByPattern = async (dir: string, filePattern: string): Promise => { + const fullDirPattern = path.resolve(dir, filePattern); + const files = await getFileNamesByPattern(fullDirPattern); + + if (files.length > 0) { + return path.extname(files[0]); + } + + return null; +}; + +export const genASTParserOptionsByFileExtension = (fileExtension: string, sourceType: string = 'module'): object => { + switch (fileExtension) { + case FILE_EXTENSION.JS: + return { + sourceType: sourceType + }; + case FILE_EXTENSION.MJS: + return { + sourceType: sourceType, + plugins: ['jsx'] + }; + case FILE_EXTENSION.TS: + return { + sourceType: sourceType, + plugins: ['typescript'] + }; + default: + return {}; + } +}; + +export const genImportDeclaration = (fileExtension: string, dependency: string): t.VariableDeclaration | t.ImportDeclaration | null => { + switch (fileExtension) { + case FILE_EXTENSION.JS: + return t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(dependency), + t.callExpression(t.identifier('require'), [t.stringLiteral(dependency)]) + ) + ]); + case FILE_EXTENSION.MJS: + return t.importDeclaration( + [t.importDefaultSpecifier(t.identifier(dependency))], + t.stringLiteral(dependency) + ); + default: + return null; + } +}; + +export const checkVariableDeclarationExist = (path: NodePath, dependency: string): boolean => { + return t.isIdentifier(path.node.id, { name: dependency }) && + t.isCallExpression(path.node.init) && + (path.node.init.callee as t.V8IntrinsicIdentifier).name === 'require' && + (path.node.init.arguments[0] as any).value === dependency; +}; + +export const isSupportFileExtension = (fileExtension: string): boolean => { + return [FILE_EXTENSION.JS, FILE_EXTENSION.MJS].indexOf(fileExtension as FILE_EXTENSION) !== -1; +}; + +export const isViteProjectSupportFileExtension = (fileExtension: string): boolean => { + return [FILE_EXTENSION.JS, FILE_EXTENSION.TS].indexOf(fileExtension as FILE_EXTENSION) !== -1; +}; diff --git a/utils/src/verify/index.ts b/utils/src/verify/index.ts new file mode 100644 index 000000000..a36822ea0 --- /dev/null +++ b/utils/src/verify/index.ts @@ -0,0 +1,19 @@ +import { VerifyStage, type VerifyCallback } from '..'; +import { ONLOOK_PLUGIN } from '../constants'; +import { hasDependency } from '../utils'; + +export const verifyProject = async (targetPath: string, onProgress: VerifyCallback): Promise => { + try { + for (const dep of [ONLOOK_PLUGIN.BABEL, ONLOOK_PLUGIN.NEXTJS]) { + onProgress(VerifyStage.CHECKING, `Checking for ${dep}`); + if (await hasDependency(dep, targetPath)) { + onProgress(VerifyStage.INSTALLED, `Found ${dep}`); + return; + } + } + onProgress(VerifyStage.NOT_INSTALLED, 'No Onlook dependencies found.'); + } catch (e: any) { + console.error(e); + onProgress(VerifyStage.ERROR, `Error verifying project. ${e.message}`); + } +}; diff --git a/utils/tsconfig.json b/utils/tsconfig.json new file mode 100644 index 000000000..dcc6b5ae6 --- /dev/null +++ b/utils/tsconfig.json @@ -0,0 +1,31 @@ +{ + "include": [ + "src", + "tests" + ], + "compilerOptions": { + // Enable latest features + "lib": [ + "ESNext", + "DOM" + ], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + } +} \ No newline at end of file