diff --git a/internal/cli/registry.go b/internal/cli/registry.go index 300ca9d..b745bc5 100644 --- a/internal/cli/registry.go +++ b/internal/cli/registry.go @@ -8,6 +8,7 @@ import ( "github.com/b-jonathan/taco/internal/stacks/firebase" "github.com/b-jonathan/taco/internal/stacks/mongodb" "github.com/b-jonathan/taco/internal/stacks/nextjs" + "github.com/b-jonathan/taco/internal/stacks/vite" ) type Stack = stacks.Stack @@ -15,6 +16,7 @@ type Stack = stacks.Stack var Registry = map[string]Stack{ "express": express.New(), "nextjs": nextjs.New(), + "vite": vite.New(), "mongodb": mongodb.New(), "firebase": firebase.New(), // TODO: implement Firebase stack "none": nil, diff --git a/internal/cli/root.go b/internal/cli/root.go index 5f00fe5..d526e80 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -167,7 +167,7 @@ func initCmd() *cobra.Command { //TODO: We're gonna have to refactor this into a "dependency-style" selection, so only db's supported by chosen backend are seen - stack["frontend"], _ = prompt.CreateSurveySelect("Choose a Frontend Stack:\n", []string{"NextJS", "None"}, prompt.AskOpts{}) + stack["frontend"], _ = prompt.CreateSurveySelect("Choose a Frontend Stack:\n", []string{"NextJS", "Vite", "None"}, prompt.AskOpts{}) stack["frontend"] = strings.ToLower(stack["frontend"]) frontend, err := GetFactory(stack["frontend"]) if err != nil { @@ -187,11 +187,17 @@ func initCmd() *cobra.Command { return err } - stack["auth"], _ = prompt.CreateSurveySelect("Choose an Auth Stack:\n", []string{"Firebase", "None"}, prompt.AskOpts{}) - stack["auth"] = strings.ToLower(stack["auth"]) - auth, err := GetFactory(stack["auth"]) - if err != nil { - return err + // Firebase only supports Next.js currently + var auth stacks.Stack + if stack["frontend"] == "nextjs" { + stack["auth"], _ = prompt.CreateSurveySelect("Choose an Auth Stack:\n", []string{"Firebase", "None"}, prompt.AskOpts{}) + stack["auth"] = strings.ToLower(stack["auth"]) + auth, err = GetFactory(stack["auth"]) + if err != nil { + return err + } + } else { + stack["auth"] = "none" } opts := &stacks.Options{ diff --git a/internal/stacks/templates/embed.go b/internal/stacks/templates/embed.go index 861d8e5..57c83f8 100644 --- a/internal/stacks/templates/embed.go +++ b/internal/stacks/templates/embed.go @@ -2,5 +2,5 @@ package templates import "embed" -//go:embed express/* firebase/* mongodb/* nextjs/* +//go:embed express/* firebase/* mongodb/* nextjs/* vite/* var FS embed.FS diff --git a/internal/stacks/templates/vite/.prettierrc.json.tmpl b/internal/stacks/templates/vite/.prettierrc.json.tmpl new file mode 100644 index 0000000..ea769fc --- /dev/null +++ b/internal/stacks/templates/vite/.prettierrc.json.tmpl @@ -0,0 +1,7 @@ +{ +"tabWidth": 2, +"semi": true, +"singleQuote": false, +"trailingComma": "all", +"plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/internal/stacks/templates/vite/eslint.config.mjs.tmpl b/internal/stacks/templates/vite/eslint.config.mjs.tmpl new file mode 100644 index 0000000..802c5ca --- /dev/null +++ b/internal/stacks/templates/vite/eslint.config.mjs.tmpl @@ -0,0 +1,29 @@ +// eslint.config.mjs +/* eslint-disable */ +import js from '@eslint/js'; +import globals from 'globals'; +import ts from 'typescript-eslint'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; + +export default [ + { ignores: ['node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/.cache/**'] }, + js.configs.recommended, + ...ts.configs.recommendedTypeChecked, + { + files: ['src/**/*.{ts,tsx,js,jsx}'], + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + } + } +]; diff --git a/internal/stacks/templates/vite/src/App.tsx.tmpl b/internal/stacks/templates/vite/src/App.tsx.tmpl new file mode 100644 index 0000000..9318e28 --- /dev/null +++ b/internal/stacks/templates/vite/src/App.tsx.tmpl @@ -0,0 +1,20 @@ +import { BrowserRouter, Routes, Route, Link } from "react-router"; +import Home from "./pages/Home"; +import About from "./pages/About"; + +function App() { + return ( + + + + } /> + } /> + + + ); +} + +export default App; + diff --git a/internal/stacks/templates/vite/src/index.css.tmpl b/internal/stacks/templates/vite/src/index.css.tmpl new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/internal/stacks/templates/vite/src/index.css.tmpl @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/internal/stacks/templates/vite/src/pages/About.tsx.tmpl b/internal/stacks/templates/vite/src/pages/About.tsx.tmpl new file mode 100644 index 0000000..8cbe101 --- /dev/null +++ b/internal/stacks/templates/vite/src/pages/About.tsx.tmpl @@ -0,0 +1,6 @@ +function About() { + return
About page
; +} + +export default About; + diff --git a/internal/stacks/templates/vite/src/pages/Home.tsx.tmpl b/internal/stacks/templates/vite/src/pages/Home.tsx.tmpl new file mode 100644 index 0000000..b771345 --- /dev/null +++ b/internal/stacks/templates/vite/src/pages/Home.tsx.tmpl @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +function Home() { + const [message, setMessage] = useState("loading..."); + + useEffect(() => { + fetch(import.meta.env.VITE_BACKEND_URL || "http://localhost:4000/") + .then((res) => res.text()) + .then(setMessage) + .catch((err) => setMessage("error: " + err.message)); + }, []); + + return
{message}
; +} + +export default Home; + diff --git a/internal/stacks/templates/vite/vite.config.ts.tmpl b/internal/stacks/templates/vite/vite.config.ts.tmpl new file mode 100644 index 0000000..c4069b7 --- /dev/null +++ b/internal/stacks/templates/vite/vite.config.ts.tmpl @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], +}) diff --git a/internal/stacks/vite/vite.go b/internal/stacks/vite/vite.go new file mode 100644 index 0000000..679b2de --- /dev/null +++ b/internal/stacks/vite/vite.go @@ -0,0 +1,138 @@ +package vite + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/b-jonathan/taco/internal/execx" + "github.com/b-jonathan/taco/internal/fsutil" + "github.com/b-jonathan/taco/internal/nodepkg" + "github.com/b-jonathan/taco/internal/stacks" + "github.com/spf13/afero" +) + +type Stack = stacks.Stack +type Options = stacks.Options + +type vite struct{} + +func New() Stack { return &vite{} } + +func (vite) Type() string { return "frontend" } + +func (vite) Name() string { return "vite" } + +func (vite) Init(ctx context.Context, opts *Options) error { + if err := fsutil.Fs.MkdirAll(opts.ProjectRoot, 0o755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + + // 1) Scaffold Vite with React + TypeScript template + viteFlags := []string{ + "--yes", + "create-vite@latest", + "frontend", + "--template", "react-ts", + } + + if err := execx.RunCmd(ctx, opts.ProjectRoot, "npx "+strings.Join(viteFlags, " ")); err != nil { + return fmt.Errorf("create-vite: %w", err) + } + + frontendDir := filepath.Join(opts.ProjectRoot, "frontend") + + // 2) Install base dependencies + if err := execx.RunCmd(ctx, frontendDir, "npm install"); err != nil { + return fmt.Errorf("npm install: %w", err) + } + + // 3) Install React Router + if err := execx.RunCmd(ctx, frontendDir, "npm install react-router"); err != nil { + return fmt.Errorf("npm install react-router: %w", err) + } + + // 4) Install Tailwind CSS and its dependencies + tailwindDeps := []string{ + "tailwindcss", + "@tailwindcss/vite", + } + if err := execx.RunCmd(ctx, frontendDir, "npm install -D "+strings.Join(tailwindDeps, " ")); err != nil { + return fmt.Errorf("npm install tailwind deps: %w", err) + } + + // 5) Install ESLint + Prettier + devDeps := []string{ + "eslint", + "@eslint/js", + "globals", + "typescript", + "typescript-eslint", + "eslint-plugin-react-hooks", + "eslint-plugin-react-refresh", + "eslint-config-prettier", + "prettier", + "prettier-plugin-tailwindcss", + } + if err := execx.RunCmd(ctx, frontendDir, "npm install -D "+strings.Join(devDeps, " ")); err != nil { + return fmt.Errorf("npm install dev deps: %w", err) + } + + return nil +} + +func (vite) Generate(ctx context.Context, opts *Options) error { + frontendDir := filepath.Join(opts.ProjectRoot, "frontend") + + templateDir := "vite" + outputDir := filepath.Join(frontendDir) + + if err := fsutil.GenerateFromTemplateDir(templateDir, outputDir); err != nil { + return fmt.Errorf("generate vite templates: %w", err) + } + + packageParams := nodepkg.InitPackageParams{ + Name: "frontend", + Scripts: map[string]string{ + "lint-check": "eslint . && prettier --check .", + "lint-fix": "eslint . --fix && prettier --write .", + }, + } + + if err := nodepkg.InitPackage(frontendDir, packageParams); err != nil { + return fmt.Errorf("init vite package.json: %w", err) + } + + return nil +} + +func (vite) Post(ctx context.Context, opts *Options) error { + frontendDir := filepath.Join(opts.ProjectRoot, "frontend") + + // Create .env file with VITE_BACKEND_URL + envPath := filepath.Join(frontendDir, ".env") + if err := fsutil.EnsureFile(envPath); err != nil { + return fmt.Errorf("ensure .env: %w", err) + } + + dir := filepath.Dir(envPath) + if err := fsutil.Fs.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + content := `VITE_BACKEND_URL=http://localhost:4000` + if err := afero.WriteFile(fsutil.Fs, envPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("write %s: %w", envPath, err) + } + + // Update root .gitignore + gitignorePath := filepath.Join(opts.ProjectRoot, ".gitignore") + if err := fsutil.EnsureFile(gitignorePath); err != nil { + return fmt.Errorf("ensure gitignore file: %w", err) + } + + _ = fsutil.AppendUniqueLines(gitignorePath, + []string{"frontend/node_modules/", "frontend/dist/", "frontend/.env*"}) + + return nil +}