├── .gitignore ├── assets └── gospur.png ├── template ├── public │ └── golang.jpg ├── base │ ├── .dockerignore.tmpl │ ├── gitignore.tmpl │ ├── tailwind.config.js.tmpl │ ├── globals.css.tmpl │ ├── package.json.tmpl │ ├── esbuild.config.js.tmpl │ ├── main.go.tmpl │ ├── .dockerfile.tmpl │ ├── makefile.tmpl │ ├── env.go.tmpl │ ├── readme.md.tmpl │ ├── build_dev.go.tmpl │ └── build_prod.go.tmpl ├── embed.go └── api │ ├── handler.go.chi.tmpl │ ├── route.go.chi.tmpl │ ├── route.go.echo.tmpl │ ├── route.go.fiber.tmpl │ ├── handler.go.fiber.tmpl │ ├── handler.go.echo.tmpl │ ├── api.go.echo.tmpl │ ├── api.go.fiber.tmpl │ └── api.go.chi.tmpl ├── docs ├── recommendations │ ├── index.md │ └── http-error-handling.md ├── configuration.md ├── development-usage.md ├── go-seperate-client.md ├── go-echo-templates.md ├── ci-examples.md ├── go-chi-templates.md └── go-fiber-templates.md ├── main.go ├── Makefile ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── go.mod ├── util ├── template_util_test.go ├── util_test.go ├── command_util_test.go ├── rawdata_util.go ├── util.go ├── command_util.go └── template_util.go ├── flag.go ├── .goreleaser.yaml ├── LICENSE ├── ui ├── spinner.go └── multiselect.go ├── config ├── setting.go └── config.go ├── cli.go ├── README.md ├── go.sum └── command.go /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | .env 3 | testcli 4 | dist 5 | tmp 6 | -------------------------------------------------------------------------------- /assets/gospur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilotpaul/gospur/HEAD/assets/gospur.png -------------------------------------------------------------------------------- /template/public/golang.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilotpaul/gospur/HEAD/template/public/golang.jpg -------------------------------------------------------------------------------- /template/base/.dockerignore.tmpl: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | tmp 4 | build 5 | dist 6 | public/bundle 7 | .env 8 | -------------------------------------------------------------------------------- /docs/recommendations/index.md: -------------------------------------------------------------------------------- 1 | # Recommendations 2 | 3 | - [HTTP Error Handling](/docs/recommendations/http-error-handling.md) -------------------------------------------------------------------------------- /template/base/gitignore.tmpl: -------------------------------------------------------------------------------- 1 | # Ignore node_modules 2 | node_modules 3 | 4 | # Ignore secrets 5 | .env 6 | 7 | # Build outputs (PROD) 8 | bin 9 | tmp 10 | build 11 | dist 12 | public/bundle 13 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/nilotpaul/gospur/config" 8 | ) 9 | 10 | func main() { 11 | if err := Execute(); err != nil { 12 | fmt.Println(config.ErrMsg("GoSpur exited")) 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /template/embed.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | //go:embed base/* 8 | var base embed.FS 9 | 10 | //go:embed api/* 11 | var api embed.FS 12 | 13 | //go:embed public/golang.jpg 14 | var img []byte 15 | 16 | func GetBaseFiles() embed.FS { 17 | return base 18 | } 19 | 20 | func GetAPIFiles() embed.FS { 21 | return api 22 | } 23 | 24 | func GetGolangImage() []byte { 25 | return img 26 | } 27 | -------------------------------------------------------------------------------- /template/base/tailwind.config.js.tmpl: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["web/**/*.html"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | darkMode: "class", 8 | plugins: [ 9 | {{- if .UI.HasDaisy }} 10 | require("daisyui"), 11 | {{- end }} 12 | {{- if .UI.HasPreline }} 13 | require("preline/plugin"), 14 | {{- end }} 15 | require("@tailwindcss/forms"), 16 | require("@tailwindcss/typography"), 17 | ], 18 | } 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BUILD_DIR = ./bin 2 | BINARY = $(BUILD_DIR)/gospur 3 | 4 | # CLI Commands 5 | init: build 6 | @$(BINARY) init 7 | 8 | version: build 9 | @$(BINARY) version 10 | 11 | # Development Commands 12 | run: build 13 | @$(BINARY) 14 | 15 | build: 16 | @go build -o $(BINARY) . 17 | @GOOS=windows GOARCH=amd64 go build -o $(BINARY).exe . 18 | 19 | test: 20 | @go test -v ./... 21 | 22 | test-race: 23 | @go test -v ./... --race 24 | 25 | # Only for local testing 26 | release: 27 | @goreleaser release --snapshot --clean -------------------------------------------------------------------------------- /template/base/globals.css.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .UI.HasTailwind3 -}} 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | {{- else if .UI.HasTailwind4 -}} 6 | @import 'tailwindcss'; 7 | @plugin "@tailwindcss/typography"; 8 | @plugin "@tailwindcss/forms"; 9 | {{- else -}} 10 | h1 { 11 | color: red; 12 | text-align: center; 13 | } 14 | 15 | .container { 16 | max-width: 48rem; 17 | margin: 0 auto; 18 | display: flex; 19 | flex-direction: column; 20 | text-align: center; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | {{- end }} -------------------------------------------------------------------------------- /template/api/handler.go.chi.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package api 3 | 4 | import ( 5 | "net/http" 6 | ) 7 | 8 | func handleGetHome(w http.ResponseWriter, r *http.Request) { 9 | templates.Render(w, http.StatusOK, "Home.html", map[string]any{ 10 | "Title": "GoSpur Stack", 11 | "Desc": "Best for building Full-Stack Applications with minimal JavaScript", 12 | }, "Root.html") 13 | } 14 | {{- else if .Render.IsSeperate -}} 15 | package api 16 | 17 | import ( 18 | "net/http" 19 | ) 20 | 21 | func handleGetHealth(w http.ResponseWriter, r *http.Request) { 22 | w.WriteHeader(http.StatusOK) 23 | w.Write([]byte("OK")) 24 | } 25 | {{- end -}} -------------------------------------------------------------------------------- /template/base/package.json.tmpl: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "esbuild": "^0.25.0"{{ if .Extras.HasHTMX }}, 4 | "htmx.org": "^1.9.12"{{ end }}, 5 | "livereload": "^0.9.3"{{ if .UI.HasPreline }}, 6 | "preline": "^2.7.0"{{ end }}{{ if .UI.HasDaisy }}, 7 | "daisyui": "^4.12.23"{{ end }}{{ if .UI.HasTailwind3 }}, 8 | "esbuild-plugin-tailwindcss": "^1.2.3", 9 | "@tailwindcss/forms": "^0.5.10", 10 | "@tailwindcss/typography": "^0.5.16", 11 | "tailwindcss": "^3.4.16"{{ end }}{{ if .UI.HasTailwind4 }}, 12 | "esbuild-plugin-tailwindcss": "^2.0.1", 13 | "@tailwindcss/forms": "^0.5.10", 14 | "@tailwindcss/typography": "^0.5.16", 15 | "tailwindcss": "^4.1.8"{{ end }} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Integration Test for GoSpur CLI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - cli 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | go-version: [ '1.23.x' ] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Setup Go ${{ matrix.go-version }} 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ matrix.go-version }} 25 | 26 | - name: Display Go version 27 | run: go version 28 | 29 | - name: Install Dependencies 30 | run: go mod tidy 31 | 32 | - name: Run Tests 33 | run: go test -v ./... --race -------------------------------------------------------------------------------- /template/base/esbuild.config.js.tmpl: -------------------------------------------------------------------------------- 1 | const { build } = require("esbuild"); 2 | {{- if .UI.HasTailwind }} 3 | const { tailwindPlugin } = require("esbuild-plugin-tailwindcss"); 4 | {{- end }} 5 | 6 | const b = () => 7 | build({ 8 | bundle: true, 9 | entryPoints: [ 10 | "web/styles/*", 11 | {{- if .UI.HasPreline }} 12 | "node_modules/preline/preline.js", 13 | {{- end }} 14 | {{- if .Extras.HasHTMX }} 15 | "node_modules/htmx.org/dist/htmx.js", 16 | {{- end }} 17 | ], 18 | {{- if .UI.HasTailwind }} 19 | plugins: [tailwindPlugin()], 20 | {{- end }} 21 | entryNames: "[name]", 22 | platform: "browser", 23 | outdir: "public/bundle", 24 | format: "cjs", 25 | minify: true, 26 | }); 27 | 28 | Promise.all([b()]); 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | go-version: [ '1.23.x' ] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup Go ${{ matrix.go-version }} 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go-version }} 24 | - 25 | name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v5.0.0 27 | with: 28 | distribution: goreleaser 29 | version: ${{ env.GITHUB_REF_NAME }} 30 | args: release --clean 31 | workdir: ./ 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nilotpaul/gospur 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf 7 | github.com/manifoldco/promptui v0.9.0 8 | github.com/spf13/cobra v1.8.1 9 | github.com/stretchr/testify v1.9.0 10 | github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 11 | ) 12 | 13 | require ( 14 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 17 | github.com/kr/pretty v0.1.0 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | github.com/spf13/pflag v1.0.5 // indirect 20 | golang.org/x/net v0.35.0 // indirect 21 | golang.org/x/sys v0.30.0 // indirect 22 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 23 | gopkg.in/yaml.v3 v3.0.1 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /util/template_util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSkipProjectfiles(t *testing.T) { 10 | t.Parallel() 11 | a := assert.New(t) 12 | 13 | mockStackCfg := StackConfig{ 14 | WebFramework: "Echo", 15 | } 16 | 17 | // With tailwind3, tailwind.config.js is needed. 18 | mockStackCfg.CssStrategy = "Tailwind3" 19 | skip := skipProjectfiles("tailwind.config.js", mockStackCfg) 20 | a.False(skip) 21 | 22 | // With tailwind4, tailwind.config.js is not needed. 23 | mockStackCfg.CssStrategy = "Tailwind4" 24 | skip = skipProjectfiles("tailwind.config.js", mockStackCfg) 25 | a.True(skip) 26 | 27 | // With Vanilla CSS, tailwind.config.js is not needed. 28 | mockStackCfg.CssStrategy = "Vanilla" 29 | skip = skipProjectfiles("tailwind.config.js", mockStackCfg) 30 | a.True(skip) 31 | } 32 | -------------------------------------------------------------------------------- /template/api/route.go.chi.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package api 3 | 4 | import ( 5 | "{{ .ModPath }}/config" 6 | 7 | "github.com/go-chi/chi/v5" 8 | ) 9 | 10 | type Routes struct { 11 | env *config.EnvConfig 12 | } 13 | 14 | func NewRouter(env *config.EnvConfig) *Routes { 15 | return &Routes{ 16 | env: env, 17 | } 18 | } 19 | 20 | func (r *Routes) RegisterRoutes(router chi.Router) { 21 | router.Get("/", handleGetHome) 22 | } 23 | {{- else if .Render.IsSeperate -}} 24 | package api 25 | 26 | import ( 27 | "{{ .ModPath }}/config" 28 | 29 | "github.com/go-chi/chi/v5" 30 | ) 31 | 32 | type Routes struct { 33 | env *config.EnvConfig 34 | } 35 | 36 | func NewRouter(env *config.EnvConfig) *Routes { 37 | return &Routes{ 38 | env: env, 39 | } 40 | } 41 | 42 | func (r *Routes) RegisterRoutes(router chi.Router) { 43 | router.Get("/health", handleGetHealth) 44 | } 45 | {{- end -}} -------------------------------------------------------------------------------- /template/api/route.go.echo.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package api 3 | 4 | import ( 5 | "github.com/labstack/echo/v4" 6 | "{{ .ModPath }}/config" 7 | ) 8 | 9 | type Routes struct { 10 | env *config.EnvConfig 11 | } 12 | 13 | func NewRouter(env *config.EnvConfig) *Routes { 14 | return &Routes{ 15 | env: env, 16 | } 17 | } 18 | 19 | func (r *Routes) RegisterRoutes(router *echo.Router) { 20 | router.Add("GET", "/", handleGetHome) 21 | } 22 | {{- else if .Render.IsSeperate -}} 23 | package api 24 | 25 | import ( 26 | "{{ .ModPath }}/config" 27 | 28 | "github.com/labstack/echo/v4" 29 | ) 30 | 31 | type Routes struct { 32 | env *config.EnvConfig 33 | } 34 | 35 | func NewRouter(env *config.EnvConfig) *Routes { 36 | return &Routes{ 37 | env: env, 38 | } 39 | } 40 | 41 | func (r *Routes) RegisterRoutes(router *echo.Router) { 42 | router.Add("GET", "/health", handleGetHealth) 43 | } 44 | {{- end -}} -------------------------------------------------------------------------------- /template/api/route.go.fiber.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package api 3 | 4 | import ( 5 | "{{ .ModPath }}/config" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | ) 9 | 10 | type Routes struct { 11 | env *config.EnvConfig 12 | } 13 | 14 | func NewRouter(env *config.EnvConfig) *Routes { 15 | return &Routes{ 16 | env: env, 17 | } 18 | } 19 | 20 | func (r *Routes) RegisterRoutes(router fiber.Router) { 21 | router.Add("GET", "/", handleGetHome) 22 | } 23 | {{- else if .Render.IsSeperate -}} 24 | package api 25 | 26 | import ( 27 | "{{ .ModPath }}/config" 28 | 29 | "github.com/gofiber/fiber/v2" 30 | ) 31 | 32 | type Routes struct { 33 | env *config.EnvConfig 34 | } 35 | 36 | func NewRouter(env *config.EnvConfig) *Routes { 37 | return &Routes{ 38 | env: env, 39 | } 40 | } 41 | 42 | func (r *Routes) RegisterRoutes(router fiber.Router) { 43 | router.Add("GET", "/health", handleGetHealth) 44 | } 45 | {{- end -}} -------------------------------------------------------------------------------- /flag.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/nilotpaul/gospur/config" 8 | "github.com/nilotpaul/gospur/util" 9 | ) 10 | 11 | func registerInitCmdFlags() { 12 | initCmd.Flags().StringVar( 13 | &stackConfig.WebFramework, "framework", "", 14 | strings.Join(config.WebFrameworkOpts, ", "), 15 | ) 16 | initCmd.Flags().StringVar( 17 | &stackConfig.CssStrategy, "styling", "", 18 | strings.Join(config.CssStrategyOpts, ", "), 19 | ) 20 | initCmd.Flags().StringVar( 21 | &stackConfig.UILibrary, "ui", "", 22 | strings.Join(util.GetMapKeys(config.UILibraryOpts), ", "), 23 | ) 24 | initCmd.Flags().StringVar( 25 | &stackConfig.RenderingStrategy, "render", "", 26 | strings.Join(util.GetRenderingOpts(true), ", "), 27 | ) 28 | initCmd.Flags().StringSliceVar( 29 | &stackConfig.ExtraOpts, "extra", []string{}, 30 | fmt.Sprintf("One or Many: %s", strings.Join(config.ExtraOpts, ", ")), 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /template/base/main.go.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package main 3 | 4 | import ( 5 | "log" 6 | 7 | "{{ .ModPath }}/api" 8 | "{{ .ModPath }}/config" 9 | ) 10 | 11 | func main() { 12 | // Loading the env vars from either a `.env` file or runtime. 13 | env := config.MustloadEnv() 14 | 15 | // API Server 16 | server := api.NewAPIServer(env, api.ServerConfig{ 17 | ServeStatic: ServeStatic, 18 | LoadTemplates: LoadTemplates, 19 | }) 20 | 21 | log.Fatal(server.Start()) 22 | } 23 | {{- else if .Render.IsSeperate -}} 24 | package main 25 | 26 | import ( 27 | "log" 28 | 29 | "{{ .ModPath }}/api" 30 | "{{ .ModPath }}/config" 31 | ) 32 | 33 | func main() { 34 | // Loading the env vars from either a `.env` file or runtime. 35 | env := config.MustloadEnv() 36 | 37 | // API Server 38 | server := api.NewAPIServer(env, api.ServerConfig{ 39 | ServeStatic: ServeStatic, 40 | }) 41 | 42 | log.Fatal(server.Start()) 43 | } 44 | {{- end -}} -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - binary: gospur 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | goarch: 16 | - amd64 17 | - arm64 18 | - 386 19 | ldflags: | 20 | -s -w 21 | -X github.com/nilotpaul/gospur/config.version={{.Version}} 22 | -X github.com/nilotpaul/gospur/config.commit={{.Commit}} 23 | -X github.com/nilotpaul/gospur/config.date={{.Date}} 24 | 25 | archives: 26 | - formats: [ 'tar.gz' ] 27 | # this name template makes the OS and Arch compatible with the results of `uname`. 28 | name_template: >- 29 | {{ .ProjectName }}_ 30 | {{- title .Os }}_ 31 | {{- if eq .Arch "amd64" }}x86_64 32 | {{- else if eq .Arch "386" }}i386 33 | {{- else }}{{ .Arch }}{{ end }} 34 | {{- if .Arm }}v{{ .Arm }}{{ end }} 35 | # use zip for windows archives 36 | format_overrides: 37 | - goos: windows 38 | formats: [ 'zip' ] 39 | -------------------------------------------------------------------------------- /template/base/.dockerfile.tmpl: -------------------------------------------------------------------------------- 1 | # Using a node base image 2 | FROM node:20-alpine AS bundler 3 | 4 | WORKDIR /app 5 | 6 | # Coping the project in /app 7 | COPY . . 8 | 9 | # Installing node dependencies 10 | RUN npm install 11 | # Bundling, output -> public/bundle 12 | RUN node ./esbuild.config.js && rm -rf node_modules 13 | 14 | # Using Go base image 15 | FROM golang:1.23-alpine AS builder 16 | ENV GO111MODULE=on 17 | 18 | WORKDIR /app 19 | 20 | # Copying the project with bundled assets from previous stage 21 | COPY --from=bundler /app . 22 | 23 | # Insallting Go modules 24 | RUN go mod download 25 | # Built binary will be saved to bin/build 26 | RUN go build -tags '!dev' -o bin/build 27 | 28 | # Using scratch base image 29 | FROM scratch 30 | 31 | WORKDIR /app 32 | 33 | # Copying the binary and bundled assets 34 | COPY --from=builder --chown=1000:1000 /app/bin /app/bin 35 | 36 | # Switch to the non-root user 37 | USER 1000:1000 38 | 39 | # Setting up necessary environment variables 40 | ENV ENVIRONMENT="PRODUCTION" 41 | ENV PORT="3000" 42 | 43 | EXPOSE $PORT 44 | 45 | CMD [ "./bin/build" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nilotpaul Nandi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration Options 2 | 3 | > To see the available options, run gopsur init -h. The output will list all valid options, and their exact casing must be used (e.g., if an option is displayed as HTMX, it must be passed as HTMX). 4 | 5 | ## For init command 6 | 7 | These will be asked as prompts when you run `init`. If an option is selected by a flag, it'll skip that prompt. 8 | 9 | **Web Framework** 10 | - Echo 11 | - Fiber 12 | - Chi 13 | ```sh 14 | # flag 15 | --framework Echo 16 | ``` 17 | 18 | **Rendering Strategy** 19 | - Templates 20 | - Seperate Client (eg. react,svelte,etc.) 21 | ```sh 22 | # flag 23 | --render Seperate 24 | ``` 25 | 26 | **Styling** 27 | - Tailwind 4 28 | - Tailwind 3 29 | - Vanilla 30 | ```sh 31 | # flag 32 | --styling Tailwind 33 | ``` 34 | 35 | **UI Library** 36 | - Preline 37 | - DaisyUI 38 | ```sh 39 | # flag 40 | --ui DaisyUI 41 | ``` 42 | 43 | ## Options via Flags 44 | 45 | These options can only be enabled/used via flags. 46 | 47 | **Extra Options** 48 | - HTMX 49 | - Dockerfile 50 | ```sh 51 | # flag 52 | --extra Dockerfile 53 | ``` -------------------------------------------------------------------------------- /template/base/makefile.tmpl: -------------------------------------------------------------------------------- 1 | {{- if not .IsLinux -}} 2 | # As you're not using linux, please vist https://github.com/nilotpaul/gospur/blob/main/docs/development-usage.md 3 | {{- end -}} 4 | {{- if .Render.IsTemplates -}} 5 | start: 6 | @node ./esbuild.config.js 7 | @go build -tags '!dev' -o bin/build 8 | @ENVIRONMENT=PRODUCTION ./bin/build 9 | 10 | build: 11 | @node ./esbuild.config.js 12 | @go build -tags 'dev' -o bin/build 13 | 14 | dev: 15 | @wgo \ 16 | -exit \ 17 | -file=.go \ 18 | -file=.html \ 19 | -file=.css \ 20 | -xdir=public \ 21 | go build -tags 'dev' -o bin/build . \ 22 | :: ENVIRONMENT=DEVELOPMENT ./bin/build \ 23 | :: wgo -xdir=bin -xdir=node_modules -xdir=public node ./esbuild.config.js \ 24 | :: wgo -dir=node_modules npx livereload -w 800 -ee go . 25 | {{- else -}} 26 | start: 27 | @go build -tags '!dev' -o bin/build 28 | @ENVIRONMENT=PRODUCTION ./bin/build 29 | 30 | build: 31 | @go build -tags 'dev' -o bin/build 32 | 33 | dev: 34 | @wgo \ 35 | -exit \ 36 | -file=.go \ 37 | -xdir=public \ 38 | go build -tags 'dev' -o bin/build . \ 39 | :: ENVIRONMENT=DEVELOPMENT ./bin/build 40 | {{ end }} -------------------------------------------------------------------------------- /ui/spinner.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/manifoldco/promptui" 8 | ) 9 | 10 | var done = doneColor(promptui.IconGood) 11 | 12 | type Spinner struct { 13 | // Message to show beside the loading icon 14 | loadingMsg string 15 | 16 | frames []string 17 | // Spin delay 18 | delay time.Duration 19 | // Channel for stopping the spinner 20 | stopChan chan struct{} 21 | } 22 | 23 | func NewSpinner(msg string) *Spinner { 24 | return &Spinner{ 25 | loadingMsg: msg, 26 | frames: []string{"|", "/", "-", "\\"}, 27 | delay: 100 * time.Millisecond, 28 | stopChan: make(chan struct{}), 29 | } 30 | } 31 | 32 | func (s *Spinner) Start() { 33 | go func() { 34 | i := 0 35 | for { 36 | select { 37 | case <-s.stopChan: 38 | fmt.Printf("\r%s\n", done) 39 | return 40 | 41 | default: 42 | spin := promptui.Styler(promptui.FGBlue)(s.frames[i%len(s.frames)]) 43 | fmt.Printf("\r%s %s", spin, s.loadingMsg) 44 | time.Sleep(s.delay) 45 | i++ 46 | } 47 | 48 | } 49 | }() 50 | } 51 | 52 | func (s *Spinner) Stop() { 53 | close(s.stopChan) 54 | // Waiting for 100ms to keep the stdout synchronised. 55 | time.Sleep(100 * time.Millisecond) 56 | } 57 | -------------------------------------------------------------------------------- /template/base/env.go.tmpl: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/joho/godotenv" 8 | ) 9 | 10 | // Add all environment variables here. 11 | type EnvConfig struct { 12 | Environment string 13 | Port string 14 | } 15 | 16 | func (env *EnvConfig) IsProduction() bool { 17 | return env.Environment == "production" 18 | } 19 | 20 | // MustloadEnv will load env vars from a .env file. 21 | // If a .env file is not provided it'll fallback to the runtime injected ones. 22 | // It'll parse and validate the env and panic if fails to do so. 23 | func MustloadEnv() *EnvConfig { 24 | err := godotenv.Load(".env") 25 | _ = err // discarding the error for later validation 26 | 27 | env := &EnvConfig{ 28 | Environment: os.Getenv("ENVIRONMENT"), 29 | Port: os.Getenv("PORT"), 30 | } 31 | parsedEnv, err := parseEnv(env) 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | return parsedEnv 37 | } 38 | 39 | // parseEnv will validate the env variables and assign fallback values. 40 | func parseEnv(env *EnvConfig) (*EnvConfig, error) { 41 | if env == nil { 42 | return nil, fmt.Errorf("no environment variables provided") 43 | } 44 | if len(env.Port) == 0 { 45 | // Defaulting to port 3000 if none provided. 46 | env.Port = "3000" 47 | } 48 | 49 | return env, nil 50 | } 51 | -------------------------------------------------------------------------------- /config/setting.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | 7 | "github.com/manifoldco/promptui" 8 | ) 9 | 10 | const logo string = ` 11 | _____ _____ 12 | / ____| / ____| 13 | | | __ ___| (___ _ __ _ _ _ __ 14 | | | |_ |/ _ \\___ \| '_ \| | | | '__| 15 | | |__| | (_) |___) | |_) | |_| | | 16 | \_____|\___/_____/| .__/ \__,_|_| 17 | | | 18 | |_| 19 | ` 20 | 21 | const ( 22 | WinBinaryName = "gospur.exe" 23 | OtherBinaryName = "gospur" // for linux and darwin 24 | ) 25 | 26 | var LogoColoured string = promptui.Styler(promptui.FGCyan, promptui.FGBold)(logo) 27 | 28 | // GoSpur CLI version info 29 | var ( 30 | version string 31 | commit string 32 | date string 33 | ) 34 | 35 | func GetVersion() (string, error) { 36 | noInfoErr := fmt.Errorf("No version information available") 37 | 38 | // goreleaser has embeded the version via ldflags. 39 | if len(version) != 0 { 40 | return version, nil 41 | } 42 | 43 | // Try to get the version from the go.mod build info. 44 | info, ok := debug.ReadBuildInfo() 45 | if !ok { 46 | return "", noInfoErr 47 | } 48 | if info.Main.Version != "(devel)" { 49 | return info.Main.Version, nil 50 | } 51 | 52 | return "", noInfoErr 53 | } 54 | 55 | func GetSafeVersion() string { 56 | version, _ := GetVersion() 57 | return NormalMsg(version) 58 | } 59 | -------------------------------------------------------------------------------- /docs/development-usage.md: -------------------------------------------------------------------------------- 1 | # Development Usage 2 | 3 | ## Commands 4 | 5 | ```Makefile 6 | start: 7 | @node ./esbuild.config.js 8 | @go build -tags '!dev' -o bin/build 9 | @ENVIRONMENT=PRODUCTION ./bin/build 10 | 11 | build: 12 | @node ./esbuild.config.js 13 | @go build -tags 'dev' -o bin/build 14 | 15 | dev: 16 | @wgo \ 17 | -exit \ 18 | -file=.go \ 19 | -file=.html \ 20 | -file=.css \ 21 | -xdir=public \ 22 | go build -tags 'dev' -o bin/build . \ 23 | :: ENVIRONMENT=DEVELOPMENT ./bin/build \ 24 | :: wgo -xdir=bin -xdir=node_modules -xdir=public node ./esbuild.config.js \ 25 | :: wgo -dir=node_modules npx livereload -w 800 -ee go . 26 | ``` 27 | 28 | These are the default development commands which will be pre-configured for you. 29 | 30 | **These are specific to Linux only.** 31 | 32 | ### For Windows 33 | 34 | Please use git bash instead of command prompt or powershell and use the same `Makefile` above. 35 | 36 | ### If Auto Browser Reload Feels Slow 37 | 38 | Change the delay time (ms) of the command, default will be 800ms. 39 | 40 | ```sh 41 | wgo -dir=node_modules npx livereload -w 800 -ee go . 42 | ``` 43 | 44 | ## Environment Variables 45 | 46 | All the configurations will be done for you. 47 | 48 | Load env vars either by: 49 | - Creating a `.env` file. 50 | - Using runtime injected ones. 51 | 52 | Please visit your `config/env.go` to configure further. 53 | 54 | # Docs 55 | 56 | - [godotenv](https://github.com/joho/godotenv#godotenv--) 57 | -------------------------------------------------------------------------------- /template/base/readme.md.tmpl: -------------------------------------------------------------------------------- 1 | # This project was bootstraped with GoSpur CLI 2 | - [GoSpur Docs](https://github.com/nilotpaul/gospur#docs) 3 | 4 | # Development 5 | To Start the server: 6 | ``` 7 | make dev 8 | ``` 9 | 10 | ## Auto Browser Reload 11 | You can increase or decrease its latency (default 400ms). 12 | 13 | Makefile: 14 | ```Makefile 15 | wgo -dir=node_modules npx livereload -w 400 public 16 | ``` 17 | 18 | # Styling 19 | {{- if .UI.HasTailwind }} 20 | - With Tailwind no extra configuration is needed, start adding classes in any html file, it'll just work. 21 | {{- end }} 22 | - You can use plain CSS{{ if .UI.HasTailwind }} (even with Tailwind){{ end }}, again, it'll just work. 23 | {{- if .UI.HasTailwind }} 24 | - For CSS Modules please check this [guide](https://github.com/ttempaa/esbuild-plugin-tailwindcss#css-modules). 25 | {{- end }} 26 | 27 | # Deployment 28 | - You only need to run the compiled binary in the `bin` folder. All of your assets will be embedded into it as well. 29 | - Make sure to set `ENVIRONMENT=PRODUCTION` or just run `make` to start the production server. 30 | 31 | # Docs 32 | {{- if .Web.IsEcho }} 33 | - [Echo](https://echo.labstack.com) 34 | {{- end }} 35 | {{- if .Web.IsFiber }} 36 | - [Fiber](https://docs.gofiber.io) 37 | {{- end }} 38 | {{- if .Web.IsChi }} 39 | - [Chi](https://go-chi.io) 40 | {{- end }} 41 | {{- if not .Render.IsSeperate }} 42 | - [Esbuild](https://esbuild.github.io) 43 | {{- end }} 44 | {{- if .UI.HasTailwind }} 45 | - [TailwindCSS](https://tailwindcss.com) 46 | {{- end }} -------------------------------------------------------------------------------- /cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nilotpaul/gospur/config" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | // Root command 12 | // On run -> gospur. 13 | rootCmd = &cobra.Command{ 14 | Use: "gospur", 15 | Short: "Go Spur: Build web applications with Go, without the hassle of JavaScript", 16 | Long: "Go Spur is a CLI tool that helps you quickly bootstrap Go web applications without worrying about JavaScript. Focus solely on the backend, while we handle the small repetitive tasks for you.", 17 | Version: config.GetSafeVersion(), 18 | } 19 | 20 | // Project init command 21 | // On run -> gospur init. 22 | initCmd = &cobra.Command{ 23 | Use: "init", 24 | Short: "Initialize a Full-Stack Go Web Project", 25 | Args: cobra.MaximumNArgs(1), 26 | Run: handleInitCmd, 27 | } 28 | 29 | // Project Update CLI command 30 | // On run -> gospur update (latest). 31 | updateCmd = &cobra.Command{ 32 | Use: "update", 33 | Short: "Updates the CLI to the latest version", 34 | Args: cobra.NoArgs, 35 | Run: handleUpdateCmd, 36 | } 37 | 38 | // Project version command 39 | // On run -> gospur version. 40 | versionCmd = &cobra.Command{ 41 | Use: "version", 42 | Short: "Shows the current installed version", 43 | Args: cobra.NoArgs, 44 | Run: handleVersionCmd, 45 | } 46 | ) 47 | 48 | func Execute() error { 49 | fmt.Println(config.LogoColoured) 50 | return rootCmd.Execute() 51 | } 52 | 53 | func init() { 54 | // Flags for init cmd. 55 | registerInitCmdFlags() 56 | 57 | rootCmd.AddCommand( 58 | initCmd, 59 | updateCmd, 60 | versionCmd, 61 | ) 62 | 63 | } 64 | -------------------------------------------------------------------------------- /template/api/handler.go.fiber.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package api 3 | 4 | import ( 5 | "strings" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | ) 9 | 10 | func HTTPErrorHandler(c *fiber.Ctx, err error) error { 11 | var ( 12 | status int = 500 13 | msg string = "Internal Server Error" 14 | fullErr string = "Something Went Wrong" 15 | ) 16 | if he, ok := err.(*fiber.Error); ok { 17 | status = he.Code 18 | msg = he.Message 19 | fullErr = he.Error() 20 | } 21 | 22 | // If the path is prefixed with `/api/json`, send a JSON Response Back. 23 | // Otherwise, render a Error HTML Page. 24 | if strings.HasPrefix(c.Path(), "/api/json") { 25 | return c.Status(status).JSON(map[string]any{"status": status, "error": msg}) 26 | } else { 27 | return c.Status(status).Render("Error", map[string]any{"Msg": msg, "FullError": fullErr}) 28 | } 29 | } 30 | 31 | func handleGetHome(c *fiber.Ctx) error { 32 | return c.Render("Home", map[string]any{ 33 | "Title": "GoSpur Stack", 34 | "Desc": "Best for building Full-Stack Applications with minimal JavaScript", 35 | }) 36 | } 37 | {{- else if .Render.IsSeperate -}} 38 | package api 39 | 40 | import ( 41 | "github.com/gofiber/fiber/v2" 42 | ) 43 | 44 | func HTTPErrorHandler(c *fiber.Ctx, err error) error { 45 | var ( 46 | status int = 500 47 | msg string = "Internal Server Error" 48 | fullErr string = "Something Went Wrong" 49 | ) 50 | if he, ok := err.(*fiber.Error); ok { 51 | status = he.Code 52 | msg = he.Message 53 | fullErr = he.Error() 54 | } 55 | 56 | return c.Status(status).JSON(map[string]any{"status": status, "message": msg, "error": fullErr}) 57 | 58 | } 59 | 60 | func handleGetHealth(c *fiber.Ctx) error { 61 | return c.SendString("OK") 62 | } 63 | {{- end -}} -------------------------------------------------------------------------------- /util/util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestMatchBinary(t *testing.T) { 11 | t.Parallel() 12 | a := assert.New(t) 13 | 14 | // Invalid binary name 15 | ok := matchBinaryFile("bad") 16 | a.False(ok) 17 | 18 | // Invalid binary name for windows 19 | ok = matchBinaryFile("bad.exe") 20 | a.False(ok) 21 | 22 | // Correct binary name 23 | ok = matchBinaryFile("gospur") 24 | a.True(ok) 25 | 26 | // Correct binary name for windows 27 | ok = matchBinaryFile("gospur.exe") 28 | a.True(ok) 29 | } 30 | 31 | func TestFindMatchingBinary(t *testing.T) { 32 | t.Parallel() 33 | a := assert.New(t) 34 | 35 | binaries := []string{ 36 | "gospur_0.7.1_checksums.txt", 37 | "gospur_Darwin_arm64.tar.gz", 38 | "gospur_Darwin_x86_64.tar.gz", 39 | "gospur_Linux_arm64.tar.gz", 40 | "gospur_Linux_i386.tar.gz", 41 | "gospur_Linux_x86_64.tar.gz", 42 | "gospur_Windows_arm64.zip", 43 | "gospur_Windows_i386.zip", 44 | "gospur_Windows_x86_64.zip", 45 | } 46 | 47 | // Test for different OS and architectures 48 | tests := []struct { 49 | os string 50 | arch string 51 | expected string 52 | }{ 53 | {"darwin", "arm64", "gospur_Darwin_arm64.tar.gz"}, 54 | {"darwin", "amd64", "gospur_Darwin_x86_64.tar.gz"}, 55 | {"linux", "arm64", "gospur_Linux_arm64.tar.gz"}, 56 | {"linux", "386", "gospur_Linux_i386.tar.gz"}, 57 | {"linux", "amd64", "gospur_Linux_x86_64.tar.gz"}, 58 | {"windows", "arm64", "gospur_Windows_arm64.zip"}, 59 | {"windows", "386", "gospur_Windows_i386.zip"}, 60 | {"windows", "amd64", "gospur_Windows_x86_64.zip"}, 61 | } 62 | 63 | for _, test := range tests { 64 | t.Run(fmt.Sprintf("%s-%s", test.os, test.arch), func(t *testing.T) { 65 | binary := FindMatchingBinary(binaries, test.os, test.arch) 66 | a.Equal(test.expected, binary) 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /template/api/handler.go.echo.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package api 3 | 4 | import ( 5 | "net/http" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | func HTTPErrorHandler(err error, c echo.Context) { 13 | var ( 14 | status int = 500 15 | msg string = "Internal Server Error" 16 | fullErr string = "Something Went Wrong" 17 | ) 18 | if he, ok := err.(*echo.HTTPError); ok { 19 | status = he.Code 20 | msg = regexp.MustCompile(`message=([^,]+)`).FindStringSubmatch(he.Error())[1] 21 | fullErr = he.Error() 22 | } 23 | 24 | // If the path is prefixed with `/api/json`, send a JSON Response Back. 25 | // Otherwise, render a Error HTML Page. 26 | if strings.HasPrefix(c.Request().URL.Path, "/api/json") { 27 | c.JSON(status, map[string]any{"status": status, "error": msg}) 28 | } else { 29 | c.Render(http.StatusOK, "Error.html", map[string]any{ 30 | "FullError": fullErr, 31 | "Msg": msg, 32 | }) 33 | } 34 | } 35 | 36 | func handleGetHome(c echo.Context) error { 37 | return c.Render(http.StatusOK, "Home.html", map[string]any{ 38 | "Title": "GoSpur Stack", 39 | "Desc": "Best for building Full-Stack Applications with minimal JavaScript", 40 | }) 41 | } 42 | {{- else if .Render.IsSeperate -}} 43 | package api 44 | 45 | import ( 46 | "net/http" 47 | "regexp" 48 | 49 | "github.com/labstack/echo/v4" 50 | ) 51 | 52 | func HTTPErrorHandler(err error, c echo.Context) { 53 | var ( 54 | status int = 500 55 | msg string = "Internal Server Error" 56 | fullErr string = "Something Went Wrong" 57 | ) 58 | if he, ok := err.(*echo.HTTPError); ok { 59 | status = he.Code 60 | msg = regexp.MustCompile(`message=([^,]+)`).FindStringSubmatch(he.Error())[1] 61 | fullErr = he.Error() 62 | } 63 | 64 | c.JSON(status, map[string]any{"status": status, "message": msg, "error": fullErr}) 65 | } 66 | 67 | func handleGetHealth(c echo.Context) error { 68 | return c.String(http.StatusOK, "OK") 69 | } 70 | {{- end -}} -------------------------------------------------------------------------------- /docs/recommendations/http-error-handling.md: -------------------------------------------------------------------------------- 1 | # HTTP Error Handling 2 | 3 | If you're already using a framework which lets you return error in handlers, then you're all set (no need to read this). 4 | 5 | **This is for people using `http.HandlerFunc` signature for their handlers (bascially stdlib stuff).** 6 | 7 | ## Creating Custom Handler Type 8 | 9 | ```go 10 | // Basically same as `http.HandlerFunc` but retuns an error 11 | type HTTPHandlerFunc func(w http.ResponseWriter, r *http.Request) error 12 | 13 | type AppError struct { 14 | Status int `json:"status"` 15 | Msg string `json:"msg"` 16 | } 17 | 18 | func NewAppError(status int, msg string) *AppError { 19 | return &AppError{Status: status, Msg: msg} 20 | } 21 | 22 | // Implementing the error interface. 23 | func (r *AppError) Error() string { 24 | return r.Msg 25 | } 26 | ``` 27 | 28 | ## Creating a Wrapper to Handle Errors 29 | 30 | ```go 31 | func handler(h HTTPHandlerFunc) http.HandlerFunc { 32 | return func(w http.ResponseWriter, r *http.Request) { 33 | var ( 34 | status = http.StatusInternalServerError 35 | msg = "Internal Server Error" 36 | ) 37 | 38 | err := h(w, r) 39 | if err == nil { return } 40 | 41 | if res, ok := err.(*AppError); ok { 42 | status = res.Status 43 | msg = res.Msg 44 | } 45 | 46 | // Handle different cases like 404, 401, etc. 47 | // You can also render error pages with `templates.Render`. 48 | http.Error(w, msg, status) 49 | } 50 | } 51 | ``` 52 | 53 | ## Example 54 | 55 | ```go 56 | // Example Handler 57 | func handleGetSomePage(w http.ResponseWriter, r *http.Request) error { 58 | return templates.Render(w, http.StatusOK, "SomePage.html", nil) 59 | } 60 | func handlePostSomething(w http.ResponseWriter, r *http.Request) error { 61 | // Returning Errors 62 | return NewAppError(http.StatusBadRequest, "Bad!") 63 | 64 | // If OK 65 | w.WriteHeader(http.StatusOK) 66 | } 67 | 68 | mux := http.NewServeMux() 69 | mux.Handle("/some-page", handler(handleGetSomePage)) 70 | 71 | http.ListenAndServe(":3000", mux) 72 | ``` -------------------------------------------------------------------------------- /ui/multiselect.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/manifoldco/promptui" 7 | ) 8 | 9 | var doneColor = promptui.Styler(promptui.FGGreen) 10 | 11 | type MultiSelect struct { 12 | Label string 13 | Items []string 14 | 15 | // Size is the number of items that should appear on the select before scrolling is necessary. 16 | // Defaults to 5. 17 | Size int 18 | } 19 | 20 | // MultiSelect provides a prompt for selecting multiple items from a list of strings. 21 | func (ms MultiSelect) Run() ([]string, error) { 22 | // Track selections as a map for O(1) updates 23 | var ( 24 | items = ms.Items 25 | selected = make(map[int]bool) 26 | cursor = 0 27 | ) 28 | 29 | for { 30 | // Prepare items with bullets and checkmarks for rendering 31 | displayItems := make([]string, len(items)+1) // +1 for the "Done" button 32 | for i, item := range items { 33 | if selected[i] { 34 | displayItems[i] = fmt.Sprintf("%s %s", promptui.IconGood, item) // Selected 35 | } else { 36 | displayItems[i] = item // Unselected 37 | } 38 | } 39 | displayItems[len(items)] = doneColor("Click here to continue") 40 | 41 | prompt := promptui.Select{ 42 | Label: ms.Label, 43 | Items: displayItems, 44 | Size: ms.Size, 45 | CursorPos: cursor, 46 | HideSelected: true, 47 | } 48 | 49 | // Run the prompt 50 | index, _, err := prompt.Run() 51 | if err != nil { 52 | return nil, fmt.Errorf("prompt failed: %v", err) 53 | } 54 | 55 | // If the "Done" button is selected, exit the loop. 56 | if index == len(items) { 57 | break 58 | } 59 | 60 | // Toggle selection state 61 | selected[index] = !selected[index] 62 | 63 | // Remember the last cursor position 64 | cursor = index 65 | } 66 | 67 | // Collect selected items 68 | selectedItems := getSelectedItems(items, selected) 69 | 70 | return selectedItems, nil 71 | } 72 | 73 | func getSelectedItems(items []string, selected map[int]bool) []string { 74 | selectedItems := make([]string, 0) 75 | for i, isSelected := range selected { 76 | if isSelected { 77 | selectedItems = append(selectedItems, items[i]) 78 | } 79 | } 80 | 81 | return selectedItems 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoSpur 2 | 3 | GoSpur Logo 4 | 5 | A modern CLI Tool to bootstrap scalable web applications without hassling with JavaScript. It provides pre-configured developer tooling with no bloat, flexible for easy one step deployments. 6 | 7 | # What's better? 8 | 9 | - Only the necessary pre-configuration (full-control). 10 | - Auto JavaScript Bundling (Bring any npm library). 11 | - Very Fast Live Reload (server & browser). 12 | - `make dev` for dev and `make` for prod (one-click). 13 | - Extra options like tailwind, vanilla css, HTMX. 14 | 15 | 16 | # Installation 17 | 18 | ```sh 19 | go install github.com/nilotpaul/gospur@latest 20 | ``` 21 | 22 | or without installation 23 | 24 | ```sh 25 | go run github.com/nilotpaul/gospur@latest init [project-name] 26 | ``` 27 | 28 | or download prebuilt binary 29 | 30 | - [Download from here](https://github.com/nilotpaul/gospur/releases/latest) 31 | - Extract and run `./gospur init` 32 | 33 | # Usage 34 | 35 | ## Create a new project 36 | ```sh 37 | gospur init [project-name] 38 | ``` 39 | ## Update the CLI 40 | ```sh 41 | gospur update 42 | ``` 43 | 44 | Check more options by: 45 | ```sh 46 | # help for cli 47 | gospur --help 48 | # help for each command 49 | gospur init --help 50 | ``` 51 | 52 | # Docs 53 | 54 | Read detailed usage and examples of every stack configured. 55 | 56 | **(Stacks)** 57 | - [Go + Echo + Templates](/docs/go-echo-templates.md) 58 | - [Go + Fiber + Templates](/docs/go-fiber-templates.md) 59 | - [Go + Chi + Templates](/docs/go-chi-templates.md) 60 | - [Go + Seperate Client](/docs/go-seperate-client.md) 61 | 62 | **(Others)** 63 | - [Development Usage](/docs/development-usage.md) 64 | - [Recommendations](/docs/recommendations/index.md) 65 | 66 | # Configuration Options 67 | 68 | The configuration options include settings from various web frameworks to different rendering modes. For a detailed list, please check the [Configuration Options Docs](/docs/configuration.md). 69 | 70 | # Coming Soon 71 | 72 | - More Framework Options. 73 | - Different Rendering Strategies (~~seperate client~~, [templ](https://templ.guide)). 74 | - More examples and documentation. 75 | - Please suggest More 🙏🏼 76 | -------------------------------------------------------------------------------- /docs/go-seperate-client.md: -------------------------------------------------------------------------------- 1 | # Setting Up Your Frontend 2 | 3 | You have two options for integrating your frontend with the Go backend: 4 | 5 | 1. Keep frontend inside this repo 6 | 7 | - Create your frontend project inside the `web` folder. 8 | - Configure your frontend to output static files to `web/dist`. 9 | - If your build output directory is not `dist`, update it in `build_prod.go`. 10 | 11 | 2. Keep frontend in a separate repo 12 | 13 | - Build the frontend in a CI pipeline (examples given below). 14 | - Merge the generated `dist` folder into `web/dist`. 15 | 16 | ## Examples 17 | - [Basic Github Actions](/docs/ci-examples.md#basic-github-action) 18 | - [Docker Github Actions](/docs/ci-examples.md#docker--github-actions) 19 | - [Dockerfile](/docs/ci-examples.md#dockerfile-example) 20 | 21 | > **TODO: will update later.** 22 | 23 | # Serving a Separate Frontend with Go 24 | 25 | Frontend frameworks like React, Vue, Svelte, etc. generate static assets (HTML, CSS, JS), which can be served directly from a Go backend. This simplifies deployment and avoids CORS issues. 26 | 27 | # How It Works 28 | 29 | - The frontend build (`build`/`dist` folder) is embedded in Go using `embed.FS`. 30 | - If a requested file exists, it's served normally. 31 | - If not, Go serves `index.html` (for SPAs) or a dedicated error page if available (for SSGs). 32 | 33 | # SPA vs. SSG Behavior 34 | 35 | - **SPA (Single Page App)**: Always fallbacks to `index.html`, and routing/errors are handled in JavaScript. 36 | - **SSG (Static Site Generation)**: If a `404.html` or similar page exists, serve that instead of `index.html`. 37 | 38 | # Configuring Fallback Pages (if needed) 39 | 40 | Some SSG frameworks may generate a `404.html` for handling missing pages. To serve it properly: 41 | 42 | **Echo Example** 43 | ```go 44 | e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ 45 | Root: root, 46 | Index: index, 47 | Filesystem: http.FS(web), 48 | })) 49 | // Add this line 50 | e.FileFS("/*", "404.html", echo.MustSubFS(web, root)) 51 | ``` 52 | 53 | **Fiber Example** 54 | ```go 55 | app.Use(filesystem.New(filesystem.Config{ 56 | Root: http.FS(subFS), 57 | Browse: false, 58 | Index: "index.html", 59 | NotFoundFile: "404.html", // Change this 60 | })) 61 | ``` 62 | 63 | **Chi Example** 64 | ```go 65 | mux.Handle("/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 66 | path := strings.TrimPrefix(r.URL.Path, "/") 67 | 68 | if len(path) == 0 { 69 | path = "index.html" 70 | } 71 | // Check if the requested file exists 72 | _, err := subFS.Open(path) 73 | if err != nil { 74 | // If not found, serve fallback page. 75 | http.ServeFileFS(w, r, subFS, "404.html") // Change this 76 | return 77 | } 78 | 79 | fs.ServeHTTP(w, r) 80 | })) 81 | ``` -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/manifoldco/promptui" 4 | 5 | // Github 6 | const ( 7 | GitHubRepoURL = "https://github.com/nilotpaul/gospur" 8 | GitHubReleaseAPIURL = "https://api.github.com/repos/nilotpaul/gospur/releases" 9 | ) 10 | 11 | // For adding styles to console output. 12 | var ( 13 | ErrMsg = promptui.Styler(promptui.FGRed) 14 | SuccessMsg = promptui.Styler(promptui.FGGreen, promptui.FGBold) 15 | NormalMsg = promptui.Styler(promptui.FGWhite) 16 | FaintMsg = promptui.Styler(promptui.FGFaint) 17 | ) 18 | 19 | // ProjectFiles describes the structure of files to be read as templates 20 | // from `.tmpl` files and written to their target. 21 | // 22 | // `Key` corrosponds to the read location. 23 | // `Value` corrosponds to the write location. 24 | type ProjectFiles map[string]string 25 | 26 | // Prompt options. 27 | var ( 28 | WebFrameworkOpts = []string{ 29 | "Echo", 30 | "Fiber", 31 | "Chi", 32 | } 33 | CssStrategyOpts = []string{ 34 | "Tailwind4", 35 | "Tailwind3", 36 | "Vanilla", 37 | } 38 | UILibraryOpts = map[string][]string{ 39 | "Preline": {"Tailwind3", "Tailwind4"}, 40 | "DaisyUI": {"Tailwind3", "Tailwind4"}, 41 | } 42 | RenderingStrategy = map[string]string{ 43 | "Templates": "Templates", 44 | "Seperate Client (SPA)": "Seperate", 45 | } 46 | 47 | // Flags Only 48 | ExtraOpts = []string{ 49 | "HTMX", 50 | "Dockerfile", 51 | } 52 | ) 53 | 54 | // Project file structure 55 | var ( 56 | ProjectBaseFiles = map[string]string{ 57 | "config/env.go": "base/env.go.tmpl", 58 | "web/styles/globals.css": "base/globals.css.tmpl", 59 | ".gitignore": "base/gitignore.tmpl", 60 | "Makefile": "base/makefile.tmpl", 61 | "README.md": "base/readme.md.tmpl", 62 | "esbuild.config.js": "base/esbuild.config.js.tmpl", 63 | "package.json": "base/package.json.tmpl", 64 | "tailwind.config.js": "base/tailwind.config.js.tmpl", 65 | "build_dev.go": "base/build_dev.go.tmpl", 66 | "build_prod.go": "base/build_prod.go.tmpl", 67 | "Dockerfile": "base/.dockerfile.tmpl", 68 | ".dockerignore": "base/.dockerignore.tmpl", 69 | "main.go": "base/main.go.tmpl", 70 | } 71 | 72 | // Template path is not required anymore for pages. 73 | // We're processing these as raw files. 74 | ProjectPageFiles = map[string]string{ 75 | "web/Home.html": "", 76 | "web/Error.html": "", 77 | "web/layouts/Root.html": "", 78 | "web/instruction.md": "", 79 | } 80 | 81 | ProjectAPIFiles = map[string][]string{ 82 | "api/api.go": {"api/api.go.echo.tmpl", "api/api.go.fiber.tmpl", "api/api.go.chi.tmpl"}, 83 | "api/route.go": {"api/route.go.echo.tmpl", "api/route.go.fiber.tmpl", "api/route.go.chi.tmpl"}, 84 | "api/handler.go": {"api/handler.go.echo.tmpl", "api/handler.go.fiber.tmpl", "api/handler.go.chi.tmpl"}, 85 | } 86 | ) 87 | -------------------------------------------------------------------------------- /docs/go-echo-templates.md: -------------------------------------------------------------------------------- 1 | # Go + Echo + Templates 2 | 3 | This is a minimal project template designed to be highly configurable for your requirements. 4 | 5 | # Prerequisites 6 | 7 | - Go 8 | - Node.js with your preferred package manager (e.g., npm, yarn, or pnpm) 9 | - [wgo](https://github.com/bokwoon95/wgo) for live server reload. 10 | 11 | # Installation 12 | 13 | **Run: `gospur init [project-name]`** 14 | 15 | ## Post Installation 16 | 17 | ```sh 18 | # Needed for live reload 19 | go install github.com/bokwoon95/wgo@latest 20 | # Install node Deps 21 | npm install 22 | # Install Go Deps 23 | go mod tidy 24 | ``` 25 | 26 | **To start dev server run:** 27 | 28 | ```sh 29 | make dev 30 | ``` 31 | 32 | **To start prod server run:** 33 | 34 | ``` 35 | make 36 | ``` 37 | 38 | # Deployment 39 | 40 | You only need: 41 | 42 | - The built binary in `bin` folder. 43 | 44 | > **Note: All the assets in `public` and `web` folder will be embedded in the binary.** 45 | 46 | - Commands to build for production: 47 | ```sh 48 | # build cmd: 49 | node ./esbuild.config.js 50 | go build -tags '!dev' -o bin/build 51 | 52 | # run cmd: 53 | ENVIRONMENT=PRODUCTION ./bin/build 54 | ``` 55 | 56 | # How easy it is to use? 57 | 58 | ```go 59 | func handleGetHome(c echo.Context) error { 60 | return c.Render(http.StatusOK, "Home", map[string]any{ 61 | "Title": "GoSpur", 62 | "Desc": "Best for building Full-Stack Applications with minimal JavaScript", 63 | }) 64 | } 65 | ``` 66 | 67 | ```html 68 | {{ define "Home" }} 69 |

{{ .Ctx.Title }}

70 |

{{ .Ctx.Desc }}

71 | {{ end }} 72 | ``` 73 | 74 | Only this much code is needed to render a page. 75 | 76 | # Styling 77 | 78 | - If you've selected tailwind, then no extra configuration is needed, start adding classes in any html file. 79 | - You can always use plain css (even with tailwind). 80 | 81 | # Quick Tips 82 | 83 | - **HTML Routes:** Render templates using handlers like the example above. 84 | - **JSON Routes:** Prefix API endpoints with `/api/json`. The configuration ensures JSON responses even on errors. 85 | 86 | For example, `/api/json/example` will always return a JSON response, whereas `/example` would render a template or custom HTML error pages. 87 | 88 | # Advanced Usage 89 | 90 | **You can also install any npm library and use it.** 91 | 92 | 1. Install the library you want. 93 | 2. Update the esbuild configuration: 94 | 95 | ```js 96 | build({ 97 | // Add the main entrypoint 98 | entryPoints: ["node_modules/some-library/index.js"], 99 | }); 100 | ``` 101 | 102 | 3. Include the bundled script in your templates: 103 | your lib will be bundled and store in `public/bundle`, find the exact path and include in your templates. 104 | 105 | ```html 106 | 107 | 108 | ``` 109 | 110 | # Links to Documentation 111 | 112 | - [Echo](https://echo.labstack.com) 113 | - [Esbuild](https://esbuild.github.io) 114 | - [TailwindCSS](https://tailwindcss.com) -------------------------------------------------------------------------------- /template/base/build_dev.go.tmpl: -------------------------------------------------------------------------------- 1 | {{- if and .Web.IsEcho .Render.IsTemplates -}} 2 | //go:build dev 3 | // +build dev 4 | 5 | package main 6 | 7 | import ( 8 | "html/template" 9 | "log" 10 | 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | func ServeStatic(e *echo.Echo) { 15 | dir := "public" 16 | e.Static("/public", dir) 17 | } 18 | 19 | func parseTemplates(patterns ...string) (*template.Template, error) { 20 | tmpl := template.New("") 21 | 22 | for _, pattern := range patterns { 23 | parsedTmpl, err := tmpl.ParseGlob(pattern) 24 | if err != nil { 25 | return nil, err 26 | } 27 | tmpl = parsedTmpl 28 | } 29 | 30 | return tmpl, nil 31 | } 32 | 33 | func LoadTemplates(patterns ...string) *template.Template { 34 | tmpl, err := parseTemplates(patterns...) 35 | if err != nil { 36 | log.Printf("template parsing error: %+v\n", err) 37 | } 38 | return tmpl 39 | } 40 | {{- else if and .Web.IsFiber .Render.IsTemplates -}} 41 | //go:build dev 42 | // +build dev 43 | 44 | package main 45 | 46 | import ( 47 | "github.com/gofiber/fiber/v2" 48 | "github.com/gofiber/template/html/v2" 49 | ) 50 | 51 | func ServeStatic(app *fiber.App) fiber.Router { 52 | dir := "public" 53 | return app.Static("/public", dir) 54 | } 55 | 56 | func LoadTemplates() *html.Engine { 57 | return html.New("web", ".html") 58 | } 59 | {{- else if and .Web.IsChi .Render.IsTemplates -}} 60 | //go:build dev 61 | // +build dev 62 | 63 | package main 64 | 65 | import ( 66 | "html/template" 67 | "log" 68 | "net/http" 69 | "strings" 70 | 71 | "github.com/go-chi/chi/v5" 72 | ) 73 | 74 | func ServeStatic(mux *chi.Mux) { 75 | dir := "public" 76 | fs := http.FileServer(http.Dir(dir)) 77 | mux.Get("/public/*", http.StripPrefix("/public", fs).ServeHTTP) 78 | } 79 | 80 | func parseTemplates(patterns ...string) (*template.Template, error) { 81 | tmpl := template.New("") 82 | // adding the embed function for layouts 83 | tmpl.Funcs(template.FuncMap{ 84 | "embed": func(name string, data any) template.HTML { 85 | var out strings.Builder 86 | if err := tmpl.ExecuteTemplate(&out, name, data); err != nil { 87 | log.Println(err) 88 | } 89 | return template.HTML(out.String()) 90 | }, 91 | }) 92 | 93 | for _, pattern := range patterns { 94 | parsedTmpl, err := tmpl.ParseGlob(pattern) 95 | if err != nil { 96 | return nil, err 97 | } 98 | tmpl = parsedTmpl 99 | } 100 | 101 | return tmpl, nil 102 | } 103 | 104 | func LoadTemplates(patterns ...string) *template.Template { 105 | tmpl, err := parseTemplates(patterns...) 106 | if err != nil { 107 | log.Printf("template parsing error: %+v\n", err) 108 | } 109 | return tmpl 110 | } 111 | {{- end -}} 112 | {{- if and .Web.IsEcho .Render.IsSeperate -}} 113 | //go:build dev 114 | // +build dev 115 | 116 | package main 117 | 118 | import ( 119 | "github.com/labstack/echo/v4" 120 | ) 121 | 122 | func ServeStatic(*echo.Echo) {} 123 | {{ else if and .Web.IsFiber .Render.IsSeperate }} 124 | //go:build dev 125 | // +build dev 126 | 127 | package main 128 | 129 | import ( 130 | "github.com/gofiber/fiber/v2" 131 | ) 132 | 133 | func ServeStatic(*fiber.App) {} 134 | {{- else if and .Web.IsChi .Render.IsSeperate -}} 135 | //go:build dev 136 | // +build dev 137 | 138 | package main 139 | 140 | import ( 141 | "github.com/go-chi/chi/v5" 142 | ) 143 | 144 | func ServeStatic(*chi.Mux) {} 145 | {{- end -}} -------------------------------------------------------------------------------- /util/command_util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "math/rand" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetProjectPath(t *testing.T) { 12 | t.Parallel() 13 | a := assert.New(t) 14 | 15 | defaultProjectPath := "gospur" 16 | 17 | // With no given arg 18 | pp, err := GetProjectPath([]string{}) 19 | a.NoError(err) 20 | a.NotNil(pp) 21 | a.NotEmpty(pp.FullPath) 22 | a.Equal(defaultProjectPath, pp.Path) 23 | 24 | // With given arg `.` (current dir) 25 | pp, err = GetProjectPath([]string{"."}) 26 | a.NoError(err) 27 | a.NotNil(pp) 28 | a.NotEmpty(pp.FullPath) 29 | a.Equal(".", pp.Path) 30 | 31 | // With given arg `new-project` 32 | pp, err = GetProjectPath([]string{"new-project"}) 33 | a.NoError(err) 34 | a.NotNil(pp) 35 | a.NotEmpty(pp.FullPath) 36 | a.Equal("new-project", pp.Path) 37 | 38 | // With given arg `./new-project` 39 | pp, err = GetProjectPath([]string{"./new-project"}) 40 | a.NoError(err) 41 | a.NotNil(pp) 42 | a.NotEmpty(pp.FullPath) 43 | a.Equal("new-project", pp.Path) 44 | 45 | // With given arg `../new-project` 46 | pp, err = GetProjectPath([]string{"../new-project"}) 47 | a.Error(err) 48 | a.ErrorContains(err, "invalid directory path: '../new-project' contains '..'") 49 | a.Nil(pp) 50 | } 51 | 52 | func TestValidateGoModPath(t *testing.T) { 53 | t.Parallel() 54 | a := assert.New(t) 55 | 56 | // Given path is less than 3 character(s) 57 | err := validateGoModPath("ww") 58 | a.EqualError(err, "path cannot be less than 3 character(s)") 59 | 60 | // Given path contains https:// 61 | err = validateGoModPath("https://something") 62 | a.EqualError(err, "invalid path 'https://something', should not contain https") 63 | 64 | // Given path contains a space 65 | err = validateGoModPath("some thing") 66 | a.EqualError(err, "invalid path 'some thing', contains reserved characters") 67 | 68 | // Given path contains a : 69 | err = validateGoModPath("some:thing") 70 | a.EqualError(err, "invalid path 'some:thing', contains reserved characters") 71 | 72 | // Given path contains * 73 | err = validateGoModPath("some*thing") 74 | a.EqualError(err, "invalid path 'some*thing', contains reserved characters") 75 | 76 | // Given path contains ? 77 | err = validateGoModPath("github.com/paul?key=value") 78 | a.EqualError(err, "invalid path 'github.com/paul?key=value', contains reserved characters") 79 | 80 | // Given path contains | 81 | err = validateGoModPath("github.com/paul|repo") 82 | a.EqualError(err, "invalid path 'github.com/paul|repo', contains reserved characters") 83 | 84 | // Given path exceedes 255 character(s) 85 | err = validateGoModPath(generateRandomString(500)) 86 | a.EqualError(err, "exceeded maximum length") 87 | 88 | // Given path is valid 89 | err = validateGoModPath("github.com/nilotpaul/gospur") 90 | a.NoError(err) 91 | 92 | // Given another path is valid 93 | err = validateGoModPath("gospur") 94 | a.NoError(err) 95 | } 96 | 97 | // Helper func only for testing 98 | // 99 | // GenerateRandomString generates a random string of a given length 100 | func generateRandomString(length int) string { 101 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 102 | rand.NewSource(time.Now().UnixNano()) 103 | 104 | result := make([]byte, length) 105 | for i := range result { 106 | result[i] = charset[rand.Intn(len(charset))] 107 | } 108 | return string(result) 109 | } 110 | -------------------------------------------------------------------------------- /docs/ci-examples.md: -------------------------------------------------------------------------------- 1 | # Seperate Client Workflows 2 | 3 | **Examples of CI Pipeline workflows for seperate client approach.** 4 | 5 | ## Basic Github Action 6 | ```yaml 7 | name: CI Build 8 | 9 | on: 10 | push: 11 | tags: 12 | - "v*.*.*" 13 | 14 | jobs: 15 | build: 16 | name: Build 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | node-version: [22] 21 | 22 | permissions: 23 | contents: read 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v5 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | 34 | - name: Install and Build Web Assets 35 | working-directory: ./web 36 | run: | 37 | npm ci 38 | npm run build 39 | 40 | - name: Build Go Binary 41 | run: | 42 | go build -tags 'dev' -o bin/build 43 | 44 | # The binary is in `./bin/`. Do the rest... 45 | ``` 46 | 47 | **Note: This assumes the following :-** 48 | - Triggers on tag push. 49 | - Your frontend/client project is inside web directory. 50 | 51 | ## Docker + Github Actions 52 | ```yaml 53 | name: CI Build 54 | 55 | on: 56 | push: 57 | tags: 58 | - "v*.*.*" 59 | 60 | jobs: 61 | build: 62 | name: Docker Build & Push 63 | runs-on: ubuntu-latest 64 | 65 | permissions: 66 | contents: read 67 | packages: write 68 | 69 | steps: 70 | - name: Checkout 71 | uses: actions/checkout@v5 72 | 73 | - name: Extract tag name without "v" 74 | id: tag 75 | run: | 76 | TAG=${GITHUB_REF##*/} 77 | TAG_WITHOUT_V=$(echo $TAG | sed 's/^v//') 78 | echo "TAG=$TAG_WITHOUT_V" >> $GITHUB_OUTPUT 79 | 80 | - name: Log in to GHCR 81 | uses: docker/login-action@v3 82 | with: 83 | registry: ghcr.io 84 | username: ${{ github.actor }} 85 | password: ${{ secrets.GITHUB_TOKEN }} 86 | 87 | - name: Build and Push Docker Image 88 | run: | 89 | IMAGE_NAME=ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.TAG }} 90 | docker build -t $IMAGE_NAME . 91 | docker push $IMAGE_NAME 92 | ``` 93 | 94 | **Note: This assumes the following :-** 95 | - Triggers on tag push. 96 | - You're using GHCR 97 | 98 | ## Dockerfile Example 99 | ```dockerfile 100 | FROM node:22-alpine AS bundler 101 | 102 | WORKDIR /app 103 | 104 | # Copy entire project 105 | COPY . . 106 | 107 | # Go inside web dir and build client 108 | WORKDIR /app/web 109 | RUN npm ci && npm run build && rm -rf node_modules 110 | 111 | FROM golang:1.25-alpine AS builder 112 | ENV GO111MODULE=on 113 | 114 | WORKDIR /app 115 | 116 | # Copy project from bundler 117 | COPY --from=bundler /app . 118 | 119 | RUN go mod download 120 | 121 | RUN go build -tags '!dev' -o bin/build 122 | 123 | FROM scratch 124 | 125 | WORKDIR /app 126 | 127 | # Copy the binary 128 | COPY --from=builder --chown=1000:1000 /app/bin /app/bin 129 | 130 | # Switch to non-root user 131 | USER 1000:1000 132 | 133 | # Environment variables 134 | ENV ENVIRONMENT="PRODUCTION" 135 | ENV PORT="3000" 136 | 137 | EXPOSE $PORT 138 | 139 | CMD ["./bin/build"] 140 | ``` 141 | 142 | **Note: This assumes the following :-** 143 | - Triggers on tag push. 144 | - Your frontend/client project is in web directory. 145 | 146 | > **DO NOT forget to keep .dockerignore** -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 2 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 3 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= 4 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 5 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 6 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= 11 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= 12 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 13 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 14 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 15 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 16 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 17 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 18 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 19 | github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= 20 | github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 24 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 25 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 26 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 27 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 28 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 29 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 30 | github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts= 31 | github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE= 32 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 33 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 34 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 35 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 36 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 37 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 38 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 39 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 40 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 41 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 42 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "runtime" 8 | "time" 9 | 10 | "github.com/nilotpaul/gospur/config" 11 | "github.com/nilotpaul/gospur/util" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var stackConfig = &util.StackConfig{} 16 | 17 | // handleInitCmd handles the `init` command for gospur CLI. 18 | func handleInitCmd(cmd *cobra.Command, args []string) { 19 | targetPath, err := util.GetProjectPath(args) 20 | if err != nil { 21 | fmt.Println(config.ErrMsg(err)) 22 | return 23 | } 24 | 25 | // Building the stack config by talking user prompts. 26 | if err := util.GetStackConfig(stackConfig); err != nil { 27 | fmt.Println(config.ErrMsg(err)) 28 | return 29 | } 30 | 31 | cfg := *stackConfig 32 | // Validate the provided options. 33 | if err := util.ValidateStackConfig(cfg); err != nil { 34 | fmt.Println(config.ErrMsg(err)) 35 | return 36 | } 37 | 38 | // Asking for the go mod path from user. 39 | goModPath, err := util.GetGoModulePath() 40 | if err != nil { 41 | fmt.Println(config.ErrMsg(err)) 42 | return 43 | } 44 | 45 | // Creating the target project directory. 46 | // It'll check if the dir already exist and is empty or not (strict). 47 | if err := util.CreateTargetDir(targetPath.Path, true); err != nil { 48 | fmt.Println(config.ErrMsg(err)) 49 | return 50 | } 51 | 52 | // Creating the project files in the target directory. 53 | // Passing the go mod path for resolving Go imports. 54 | err = util.CreateProject( 55 | targetPath.Path, 56 | cfg, 57 | util.MakeProjectCtx(cfg, goModPath), 58 | ) 59 | if err != nil { 60 | fmt.Println(config.ErrMsg(err)) 61 | return 62 | } 63 | 64 | // Running `go mod init` with the specified name. 65 | if err := util.RunGoModInit(targetPath.FullPath, goModPath); err != nil { 66 | fmt.Println(config.ErrMsg(err)) 67 | return 68 | } 69 | 70 | util.PrintSuccessMsg(targetPath.Path, cfg) 71 | } 72 | 73 | // handleVersionCmd handles the `version` command for gospur CLI. 74 | func handleVersionCmd(cmd *cobra.Command, args []string) { 75 | version, err := config.GetVersion() 76 | if err != nil { 77 | fmt.Println("Version:", config.ErrMsg(err)) 78 | return 79 | } 80 | 81 | fmt.Println("Version:", config.NormalMsg(version)) 82 | } 83 | 84 | // handleUpdateCmd handles the update command for gospur CLI. 85 | func handleUpdateCmd(cmd *cobra.Command, args []string) { 86 | currVersion, err := config.GetVersion() 87 | if err != nil { 88 | fmt.Println(config.ErrMsg(err)) 89 | return 90 | } 91 | 92 | // Gets the currently running binary location. 93 | installedExePath, err := os.Executable() 94 | if err != nil { 95 | fmt.Println(config.ErrMsg("Failed to get the installation path: " + err.Error())) 96 | return 97 | } 98 | 99 | // Cancel fetch to github releases if took more than 2 seconds. 100 | ctx, cancel := context.WithDeadline(cmd.Context(), time.Now().Add(2*time.Second)) 101 | defer cancel() 102 | 103 | // Fetiching latest release from github api 104 | release, err := util.HandleGetRelease(ctx) 105 | if err != nil { 106 | fmt.Println(config.ErrMsg(err)) 107 | return 108 | } 109 | 110 | if currVersion == release.Version { 111 | fmt.Println(config.ErrMsg("Latest version is already installed")) 112 | return 113 | } 114 | 115 | var binaries []string 116 | for _, asset := range release.Assets { 117 | binaries = append(binaries, asset.BrowserDownloadURL) 118 | } 119 | 120 | // Finding the compatible binary from currently installed one. 121 | targetBinaryUrl := util.FindMatchingBinary(binaries, runtime.GOOS, runtime.GOARCH) 122 | if err := util.HandleUpdateCLI(targetBinaryUrl, installedExePath); err != nil { 123 | fmt.Printf("Update failed: %v\n", config.ErrMsg(err)) 124 | return 125 | } 126 | 127 | fmt.Printf("CLI has been updated to the latest version (%s)\n", config.SuccessMsg(release.Version)) 128 | } 129 | -------------------------------------------------------------------------------- /template/api/api.go.echo.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package api 3 | 4 | import ( 5 | "html/template" 6 | "io" 7 | "log" 8 | "strings" 9 | 10 | "{{ .ModPath }}/config" 11 | 12 | "github.com/labstack/echo/v4" 13 | "github.com/labstack/echo/v4/middleware" 14 | ) 15 | 16 | type ServerConfig struct { 17 | // Serving static assets from public dir. 18 | ServeStatic func(*echo.Echo) 19 | 20 | // LoadTemplates takes glob petterns and returns the executed templates. 21 | LoadTemplates func(...string) *template.Template 22 | } 23 | 24 | type APIServer struct { 25 | listenAddr string 26 | env *config.EnvConfig 27 | ServerConfig 28 | } 29 | 30 | func NewAPIServer(env *config.EnvConfig, cfg ServerConfig) *APIServer { 31 | return &APIServer{ 32 | listenAddr: ":" + env.Port, 33 | env: env, 34 | ServerConfig: cfg, 35 | } 36 | } 37 | 38 | // Start will run the API Server 39 | // Any global middlewares like Logger should be registered here. 40 | func (api *APIServer) Start() error { 41 | e := echo.New() 42 | 43 | // Global Middlewares 44 | api.registerGlobalMiddlewares(e) 45 | 46 | // Routes 47 | r := NewRouter(api.env) 48 | r.RegisterRoutes(e.Router()) 49 | 50 | // Static routes 51 | api.ServeStatic(e) 52 | 53 | log.Printf("Visit http://localhost%s", api.listenAddr) 54 | 55 | return e.Start(api.listenAddr) 56 | } 57 | 58 | type Template struct { 59 | templates *template.Template 60 | isDev bool 61 | } 62 | 63 | func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 64 | return t.templates.ExecuteTemplate(w, name, map[string]any{ 65 | "IsDev": t.isDev, 66 | "Ctx": data, 67 | }, 68 | ) 69 | } 70 | 71 | // Middlewares 72 | func (api *APIServer) registerGlobalMiddlewares(e *echo.Echo) { 73 | e.Use(middleware.Recover()) 74 | e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 75 | Skipper: func(c echo.Context) bool { 76 | // Skipping Logging of public assets. 77 | return strings.HasPrefix(c.Path(), "/public") 78 | }, 79 | Format: "-> '${uri}' - ${method} (${status})\n", 80 | })) 81 | 82 | e.HTTPErrorHandler = HTTPErrorHandler 83 | e.Renderer = &Template{ 84 | templates: api.LoadTemplates("web/*.html"), // add more here 85 | isDev: !api.env.IsProduction(), 86 | } 87 | } 88 | {{- else if .Render.IsSeperate -}} 89 | package api 90 | 91 | import ( 92 | "log" 93 | 94 | "{{ .ModPath }}/config" 95 | 96 | "github.com/labstack/echo/v4" 97 | "github.com/labstack/echo/v4/middleware" 98 | ) 99 | 100 | type ServerConfig struct { 101 | // Serving static assets from public dir. 102 | ServeStatic func(*echo.Echo) 103 | } 104 | 105 | type APIServer struct { 106 | listenAddr string 107 | env *config.EnvConfig 108 | ServerConfig 109 | } 110 | 111 | func NewAPIServer(env *config.EnvConfig, cfg ServerConfig) *APIServer { 112 | return &APIServer{ 113 | listenAddr: ":" + env.Port, 114 | env: env, 115 | ServerConfig: cfg, 116 | } 117 | } 118 | 119 | // Start will run the API Server 120 | // Any global middlewares like Logger should be registered here. 121 | func (api *APIServer) Start() error { 122 | e := echo.New() 123 | 124 | // Global Middlewares 125 | api.registerGlobalMiddlewares(e) 126 | 127 | // Routes 128 | r := NewRouter(api.env) 129 | r.RegisterRoutes(e.Router()) 130 | 131 | // Static routes 132 | api.ServeStatic(e) 133 | 134 | log.Printf("Visit http://localhost%s", api.listenAddr) 135 | 136 | return e.Start(api.listenAddr) 137 | } 138 | 139 | // Extend the list of global middlewares as needed. 140 | func (api *APIServer) registerGlobalMiddlewares(e *echo.Echo) { 141 | e.Use(middleware.Recover()) 142 | e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 143 | Format: "-> '${uri}' - ${method} (${status})\n", 144 | })) 145 | 146 | e.HTTPErrorHandler = HTTPErrorHandler 147 | } 148 | {{- end -}} -------------------------------------------------------------------------------- /template/api/api.go.fiber.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package api 3 | 4 | import ( 5 | "io" 6 | "log" 7 | "strings" 8 | 9 | "test/config" 10 | 11 | "github.com/gofiber/fiber/v2" 12 | "github.com/gofiber/fiber/v2/middleware/logger" 13 | "github.com/gofiber/fiber/v2/middleware/recover" 14 | "github.com/gofiber/template/html/v2" 15 | ) 16 | 17 | type ServerConfig struct { 18 | // Serving static assets from public folder. 19 | ServeStatic func(*fiber.App) fiber.Router 20 | 21 | // LoadTemplates will return the executed html templates. 22 | LoadTemplates func() *html.Engine 23 | } 24 | 25 | type APIServer struct { 26 | listenAddr string 27 | env *config.EnvConfig 28 | ServerConfig 29 | } 30 | 31 | func NewAPIServer(env *config.EnvConfig, cfg ServerConfig) *APIServer { 32 | return &APIServer{ 33 | listenAddr: ":" + env.Port, 34 | env: env, 35 | ServerConfig: cfg, 36 | } 37 | } 38 | 39 | type TemplatesEngine struct { 40 | engine *html.Engine 41 | isDev bool 42 | } 43 | 44 | func (t *TemplatesEngine) Load() error { 45 | return t.engine.Load() 46 | } 47 | 48 | // Overriding Render func 49 | func (t *TemplatesEngine) Render(w io.Writer, name string, data interface{}, layouts ...string) error { 50 | return t.engine.Render(w, name, map[string]any{"IsDev": t.isDev, "Ctx": data}, layouts...) 51 | } 52 | 53 | // Start will run the API Server 54 | // Any global middlewares like Logger should be registered here. 55 | func (api *APIServer) Start() error { 56 | app := fiber.New(fiber.Config{ 57 | ErrorHandler: HTTPErrorHandler, 58 | Views: &TemplatesEngine{ 59 | engine: api.LoadTemplates(), 60 | isDev: !api.env.IsProduction(), 61 | }, 62 | ViewsLayout: "layouts/Root", 63 | }) 64 | 65 | // Global Middlewares 66 | api.registerGlobalMiddlewares(app) 67 | 68 | // Routes 69 | r := NewRouter(api.env) 70 | r.RegisterRoutes(app) 71 | 72 | // Static routes 73 | api.ServeStatic(app) 74 | 75 | log.Printf("Visit http://localhost%s", api.listenAddr) 76 | 77 | return app.Listen(api.listenAddr) 78 | } 79 | 80 | // Middlewares 81 | func (api *APIServer) registerGlobalMiddlewares(app *fiber.App) { 82 | app.Use(recover.New()) 83 | app.Use(logger.New(logger.Config{ 84 | Next: func(c *fiber.Ctx) bool { 85 | // Skipping Logging of public assets. 86 | return strings.HasPrefix(c.Path(), "/public") 87 | }, 88 | })) 89 | } 90 | {{- else if .Render.IsSeperate -}} 91 | package api 92 | 93 | import ( 94 | "log" 95 | 96 | "{{ .ModPath }}/config" 97 | 98 | "github.com/gofiber/fiber/v2" 99 | "github.com/gofiber/fiber/v2/middleware/logger" 100 | "github.com/gofiber/fiber/v2/middleware/recover" 101 | ) 102 | 103 | type ServerConfig struct { 104 | // Serving static assets from web folder. 105 | ServeStatic func(*fiber.App) 106 | } 107 | 108 | type APIServer struct { 109 | listenAddr string 110 | env *config.EnvConfig 111 | ServerConfig 112 | } 113 | 114 | func NewAPIServer(env *config.EnvConfig, cfg ServerConfig) *APIServer { 115 | return &APIServer{ 116 | listenAddr: ":" + env.Port, 117 | env: env, 118 | ServerConfig: cfg, 119 | } 120 | } 121 | 122 | // Start will run the API Server 123 | // Any global middlewares like Logger should be registered here. 124 | func (api *APIServer) Start() error { 125 | app := fiber.New(fiber.Config{ 126 | ErrorHandler: HTTPErrorHandler, 127 | }) 128 | 129 | // Global Middlewares 130 | api.registerGlobalMiddlewares(app) 131 | 132 | // Routes 133 | r := NewRouter(api.env) 134 | r.RegisterRoutes(app) 135 | 136 | // Static routes 137 | api.ServeStatic(app) 138 | 139 | log.Printf("Visit http://localhost%s", api.listenAddr) 140 | 141 | return app.Listen(api.listenAddr) 142 | } 143 | 144 | // Middlewares 145 | func (api *APIServer) registerGlobalMiddlewares(app *fiber.App) { 146 | app.Use(recover.New()) 147 | app.Use(logger.New()) 148 | } 149 | {{- end -}} -------------------------------------------------------------------------------- /template/api/api.go.chi.tmpl: -------------------------------------------------------------------------------- 1 | {{- if .Render.IsTemplates -}} 2 | package api 3 | 4 | import ( 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "net/http" 9 | "strings" 10 | 11 | "test/config" 12 | 13 | "github.com/go-chi/chi/v5" 14 | "github.com/go-chi/chi/v5/middleware" 15 | ) 16 | 17 | var templates *Template 18 | 19 | type ServerConfig struct { 20 | // Serving static assets from public dir. 21 | ServeStatic func(*chi.Mux) 22 | 23 | // LoadTemplates takes glob petterns and returns the executed templates. 24 | LoadTemplates func(...string) *template.Template 25 | } 26 | 27 | type APIServer struct { 28 | listenAddr string 29 | env *config.EnvConfig 30 | ServerConfig 31 | } 32 | 33 | func NewAPIServer(env *config.EnvConfig, cfg ServerConfig) *APIServer { 34 | return &APIServer{ 35 | listenAddr: ":" + env.Port, 36 | env: env, 37 | ServerConfig: cfg, 38 | } 39 | } 40 | 41 | type Template struct { 42 | templates *template.Template 43 | isDev bool 44 | } 45 | 46 | func (t *Template) Render(w http.ResponseWriter, status int, name string, data any, layouts ...string) error { 47 | dataMap := map[string]any{"IsDev": t.isDev, "Page": name, "Ctx": data} 48 | 49 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 50 | w.WriteHeader(status) 51 | 52 | layout := "Root.html" 53 | if len(layouts) > 0 { 54 | layout = layouts[0] 55 | } 56 | return t.templates.ExecuteTemplate(w, layout, dataMap) 57 | } 58 | 59 | // Start will run the API Server 60 | // Any global middlewares like Logger should be registered here. 61 | func (api *APIServer) Start() error { 62 | mux := chi.NewMux() 63 | templates = &Template{ 64 | templates: api.LoadTemplates("web/*.html", "web/layouts/*.html"), 65 | isDev: !api.env.IsProduction(), 66 | } 67 | 68 | // Global Middlewares 69 | api.registerGlobalMiddlewares(mux) 70 | 71 | // Routes 72 | r := NewRouter(api.env) 73 | r.RegisterRoutes(mux) 74 | 75 | // Static routes 76 | api.ServeStatic(mux) 77 | 78 | log.Printf("Visit http://localhost%s", api.listenAddr) 79 | 80 | return http.ListenAndServe(api.listenAddr, mux) 81 | } 82 | 83 | // Middlewares 84 | func (api *APIServer) registerGlobalMiddlewares(mux *chi.Mux) { 85 | mux.Use(logger) // Logger should come before Recoverer 86 | mux.Use(middleware.Recoverer) 87 | } 88 | 89 | func logger(h http.Handler) http.Handler { 90 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 91 | // Skipping Logging of public assets. 92 | if strings.HasPrefix(r.URL.Path, "/public") { 93 | h.ServeHTTP(w, r) 94 | return 95 | } 96 | 97 | fmt.Print("\n") 98 | middleware.Logger(h).ServeHTTP(w, r) 99 | }) 100 | } 101 | {{- else if .Render.IsSeperate -}} 102 | package api 103 | 104 | import ( 105 | "log" 106 | "net/http" 107 | 108 | "{{ .ModPath }}/config" 109 | 110 | "github.com/go-chi/chi/v5" 111 | "github.com/go-chi/chi/v5/middleware" 112 | ) 113 | 114 | type ServerConfig struct { 115 | // Serving static assets from web folder. 116 | ServeStatic func(*chi.Mux) 117 | } 118 | 119 | type APIServer struct { 120 | listenAddr string 121 | env *config.EnvConfig 122 | ServerConfig 123 | } 124 | 125 | func NewAPIServer(env *config.EnvConfig, cfg ServerConfig) *APIServer { 126 | return &APIServer{ 127 | listenAddr: ":" + env.Port, 128 | env: env, 129 | ServerConfig: cfg, 130 | } 131 | } 132 | 133 | // Start will run the API Server 134 | // Any global middlewares like Logger should be registered here. 135 | func (api *APIServer) Start() error { 136 | mux := chi.NewMux() 137 | 138 | // Global Middlewares 139 | api.registerGlobalMiddlewares(mux) 140 | 141 | // Routes 142 | r := NewRouter(api.env) 143 | r.RegisterRoutes(mux) 144 | 145 | // Static routes 146 | api.ServeStatic(mux) 147 | 148 | log.Printf("Visit http://localhost%s", api.listenAddr) 149 | 150 | return http.ListenAndServe(api.listenAddr, mux) 151 | } 152 | 153 | // Middlewares 154 | func (api *APIServer) registerGlobalMiddlewares(mux *chi.Mux) { 155 | mux.Use(middleware.Logger) // Logger should come before Recoverer 156 | mux.Use(middleware.Recoverer) 157 | } 158 | {{- end -}} -------------------------------------------------------------------------------- /docs/go-chi-templates.md: -------------------------------------------------------------------------------- 1 | # Go + Chi + Templates 2 | 3 | This is a minimal project template designed to be highly configurable for your requirements. 4 | 5 | # Prerequisites 6 | 7 | - Go 8 | - Node.js with your preferred package manager (e.g., npm, yarn, or pnpm) 9 | - [wgo](https://github.com/bokwoon95/wgo) for live server reload. 10 | 11 | # Installation 12 | 13 | **Run: `gospur init [project-name]`** 14 | 15 | ## Post Installation 16 | 17 | ```sh 18 | # Needed for live reload 19 | go install github.com/bokwoon95/wgo@latest 20 | # Install node Deps 21 | npm install 22 | # Install Go Deps 23 | go mod tidy 24 | ``` 25 | 26 | **To start dev server run:** 27 | 28 | ```sh 29 | make dev 30 | ``` 31 | 32 | **To start prod server run:** 33 | 34 | ``` 35 | make 36 | ``` 37 | 38 | # Deployment 39 | 40 | You only need: 41 | 42 | - The built binary in `bin` folder. 43 | 44 | > **Note: All the assets in `public` and `web` folder will be embedded in the binary.** 45 | 46 | - Commands to build for production: 47 | ```sh 48 | # build cmd: 49 | node ./esbuild.config.js 50 | go build -tags '!dev' -o bin/build 51 | 52 | # run cmd: 53 | ENVIRONMENT=PRODUCTION ./bin/build 54 | ``` 55 | 56 | # How easy it is to use? 57 | 58 | > **Note: By default it'll use the root layout** 59 | 60 | ## Simple Example 61 | ```go 62 | func handleGetHome(w http.ResponseWriter, r *http.Request) { 63 | templates.Render(w, http.StatusOK, "Home.html", map[string]any{ 64 | "Title": "GoSpur", 65 | "Desc": "Best for building Full-Stack Applications with minimal JavaScript", 66 | }) 67 | } 68 | ``` 69 | > **Note: `Render` retuns an error, it's recommended to [handle the errors centrally](/docs/recommendations/http-error-handling.md).** 70 | 71 | ```html 72 |

{{ .Ctx.Title }}

73 |

{{ .Ctx.Desc }}

74 | ``` 75 | Only this much code is needed to render a page. 76 | 77 | ## With Custom Layout 78 | ```go 79 | func handleGetOther(w http.ResponseWriter, r *http.Request) { 80 | templates.Render(w, http.StatusOK, "Other.html", map[string]any{ 81 | "Title": "Other Page", 82 | }, "Layout.html") 83 | } 84 | ``` 85 | 86 | # Templates 87 | 88 | You'd use Go HTML Templates to render a page. 89 | 90 | ## Layouts 91 | 92 | With Go Templates, it's very difficult to make a shareable layout, but we've solved the issue for you. 93 | 94 | ## Creating a Layout 95 | 96 | Create any html file in anywhere inside `web`. 97 | 98 | ```html 99 | 100 | 101 | 102 | 103 | 104 | {{ .Ctx.Title }} 105 | 106 | {{ embed .Page . }} 107 | 108 | ``` 109 | 110 | And use this as a layout like shown [above](#with-custom-layout). 111 | 112 | ## Security Concerns 113 | 114 | - The `embed` function injects the HTML of another template. 115 | - This happens entirely in our backend server. 116 | - In production, we bundle these templates in the binary, not relying on the filesystem. 117 | 118 | In conclusion, it's safe and inspired by [Fiber](https://docs.gofiber.io). 119 | 120 | # Styling 121 | 122 | - If you've selected tailwind, then no extra configuration is needed, start adding classes in any html file. 123 | - You can always use plain css (even with tailwind). 124 | 125 | # Quick Tips 126 | 127 | - **HTML Routes:** Render templates using handlers like the example above. 128 | - **JSON Routes:** Prefix API endpoints with `/api/json`. The configuration ensures JSON responses even on errors. 129 | 130 | For example, `/api/json/example` will always return a JSON response, whereas `/example` would render a template or custom HTML error pages. 131 | 132 | # Advanced Usage 133 | 134 | **You can also install any npm library and use it.** 135 | 136 | 1. Install the library you want. 137 | 2. Update the esbuild configuration: 138 | 139 | ```js 140 | build({ 141 | // Add the main entrypoint 142 | entryPoints: ["node_modules/some-library/index.js"], 143 | }); 144 | ``` 145 | 146 | 3. Include the bundled script in your templates: 147 | your lib will be bundled and store in `public/bundle`, find the exact path and include in your templates. 148 | 149 | ```html 150 | 151 | 152 | ``` 153 | 154 | # Links to Documentation 155 | 156 | - [Chi](https://go-chi.io/#/README) 157 | - [Esbuild](https://esbuild.github.io) 158 | - [TailwindCSS](https://tailwindcss.com) 159 | -------------------------------------------------------------------------------- /docs/go-fiber-templates.md: -------------------------------------------------------------------------------- 1 | # Go + Fiber + Templates 2 | 3 | This is a minimal project template designed to be highly configurable for your requirements. 4 | 5 | # Prerequisites 6 | 7 | - Go 8 | - Node.js with your preferred package manager (e.g., npm, yarn, or pnpm) 9 | - [wgo](https://github.com/bokwoon95/wgo) for live server reload. 10 | 11 | # Installation 12 | 13 | **Run: `gospur init [project-name]`** 14 | 15 | ## Post Installation 16 | 17 | ```sh 18 | # Needed for live reload 19 | go install github.com/bokwoon95/wgo@latest 20 | # Install node Deps 21 | npm install 22 | # Install Go Deps 23 | go mod tidy 24 | ``` 25 | 26 | **To start dev server run:** 27 | 28 | The Fiber server takes a few milliseconds more than others to start up, during reload it might feel a little slower, in that case pls [reduce or remove the delay](/docs/development-usage.md#if-auto-browser-reload-feels-slow). 29 | 30 | ```sh 31 | make dev 32 | ``` 33 | 34 | **To start prod server run:** 35 | 36 | ``` 37 | make 38 | ``` 39 | 40 | # Deployment 41 | 42 | You only need: 43 | 44 | - The built binary in `bin` folder. 45 | 46 | > **Note: All the assets in `public` and `web` folder will be embedded in the binary.** 47 | 48 | - Commands to build for production: 49 | ```sh 50 | # build cmd: 51 | node ./esbuild.config.js 52 | go build -tags '!dev' -o bin/build 53 | 54 | # run cmd: 55 | ENVIRONMENT=PRODUCTION ./bin/build 56 | ``` 57 | 58 | # How easy it is to use? 59 | 60 | > **Note: By default it'll use the root layout** 61 | 62 | ## Simple Example 63 | ```go 64 | func handleGetHome(c *fiber.Ctx) error { 65 | return c.Render("Home", map[string]any{ 66 | "Title": "GoSpur", 67 | "Desc": "Best for building Full-Stack Applications with minimal JavaScript", 68 | }) 69 | } 70 | ``` 71 | ```html 72 |

{{ .Ctx.Title }}

73 |

{{ .Ctx.Desc }}

74 | ``` 75 | Only this much code is needed to render a page. 76 | 77 | ## With Custom Layout 78 | ```go 79 | func handleGetHome(c *fiber.Ctx) error { 80 | return c.Render("Other", map[string]any{ 81 | "Title": "Other Page", 82 | }, "layouts/Layout.html") 83 | } 84 | ``` 85 | 86 | # Templates 87 | 88 | By default you'll get the stack with Go HTML Templates, but Fiber supports many templating engines like django. 89 | 90 | It's very easy to swap but in our case there're few extra steps. 91 | [See all supported engines](https://docs.gofiber.io/guide/templates#supported-engines). 92 | 93 | ## Using Django 94 | 95 | Install and fix the import `github.com/gofiber/template/django/v3` 96 | > **Note: Keep track of the version it might change in future.** 97 | 98 | ```go 99 | // (Only change the part shown) 100 | // 101 | // build_dev.go 102 | func LoadTemplates() *django.Engine { 103 | return django.New("web", ".html") 104 | } 105 | // build_prod.go 106 | func LoadTemplates() *django.Engine { 107 | subFS, err := fs.Sub(templateFS, "web") 108 | if err != nil { 109 | panic(err) 110 | } 111 | 112 | return html.NewFileSystem(http.FS(subFS), ".html") 113 | } 114 | // api/api.go 115 | type ServerConfig struct { 116 | LoadTemplates func() *django.Engine 117 | } 118 | type TemplatesEngine struct { 119 | engine *django.Engine 120 | } 121 | ``` 122 | 123 | # Styling 124 | 125 | - If you've selected tailwind, then no extra configuration is needed, start adding classes in any html. 126 | - You can always use plain css (even with tailwind). 127 | 128 | # Quick Tips 129 | 130 | - **HTML Routes:** Render templates using handlers like the example above. 131 | - **JSON Routes:** Prefix API endpoints with `/api/json`. The configuration ensures JSON responses even on errors. 132 | 133 | For example, `/api/json/example` will always return a JSON response, whereas `/example` would render a template or custom HTML error pages. 134 | 135 | # Advanced Usage 136 | 137 | **You can also install any npm library and use it.** 138 | 139 | 1. Install the library you want. 140 | 2. Update the esbuild configuration: 141 | 142 | ```js 143 | build({ 144 | // Add the main entrypoint 145 | entryPoints: ["node_modules/some-library/index.js"], 146 | }); 147 | ``` 148 | 149 | 3. Include the bundled script in your templates: 150 | your lib will be bundled and store in `public/bundle`, find the exact path and include in your templates. 151 | 152 | ```html 153 | 154 | 155 | ``` 156 | 157 | # Links to Documentation 158 | 159 | - [Fiber](https://docs.gofiber.io) 160 | - [Esbuild](https://esbuild.github.io) 161 | - [TailwindCSS](https://tailwindcss.com) -------------------------------------------------------------------------------- /template/base/build_prod.go.tmpl: -------------------------------------------------------------------------------- 1 | {{- if and .Web.IsEcho .Render.IsTemplates -}} 2 | //go:build !dev 3 | // +build !dev 4 | 5 | package main 6 | 7 | import ( 8 | "embed" 9 | "html/template" 10 | 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | //go:embed public/* 15 | var publicFS embed.FS 16 | 17 | //go:embed web/* 18 | var templateFS embed.FS 19 | 20 | func ServeStatic(e *echo.Echo) { 21 | root := "public" 22 | e.StaticFS("/public", echo.MustSubFS(publicFS, root)) 23 | } 24 | 25 | func parseTemplates(patterns ...string) (*template.Template, error) { 26 | tmpl := template.New("") 27 | 28 | parsedTmpl, err := tmpl.ParseFS(templateFS, patterns...) 29 | tmpl = parsedTmpl 30 | 31 | return tmpl, err 32 | } 33 | 34 | func LoadTemplates(patterns ...string) *template.Template { 35 | return template.Must(parseTemplates(patterns...)) 36 | } 37 | {{- else if and .Web.IsFiber .Render.IsTemplates -}} 38 | //go:build !dev 39 | // +build !dev 40 | 41 | package main 42 | 43 | import ( 44 | "embed" 45 | "io/fs" 46 | "net/http" 47 | 48 | "github.com/gofiber/fiber/v2" 49 | "github.com/gofiber/fiber/v2/middleware/filesystem" 50 | "github.com/gofiber/template/html/v2" 51 | ) 52 | 53 | //go:embed public/* 54 | var publicFS embed.FS 55 | 56 | //go:embed web/* 57 | var templateFS embed.FS 58 | 59 | func ServeStatic(app *fiber.App) fiber.Router { 60 | root := "public" 61 | return app.Use("/public", filesystem.New(filesystem.Config{ 62 | PathPrefix: root, 63 | Root: http.FS(publicFS), 64 | })) 65 | } 66 | 67 | func LoadTemplates() *html.Engine { 68 | subFS, err := fs.Sub(templateFS, "web") 69 | if err != nil { 70 | panic(err) 71 | } 72 | 73 | return html.NewFileSystem(http.FS(subFS), ".html") 74 | } 75 | {{- else if and .Web.IsChi .Render.IsTemplates -}} 76 | //go:build !dev 77 | // +build !dev 78 | 79 | package main 80 | 81 | import ( 82 | "embed" 83 | "html/template" 84 | "log" 85 | "net/http" 86 | "strings" 87 | 88 | "github.com/go-chi/chi/v5" 89 | ) 90 | 91 | //go:embed public/* 92 | var publicFS embed.FS 93 | 94 | //go:embed web/* 95 | var templateFS embed.FS 96 | 97 | func ServeStatic(mux *chi.Mux) { 98 | fs := http.FileServer(http.FS(publicFS)) 99 | mux.Get("/public/*", fs.ServeHTTP) 100 | } 101 | 102 | func parseTemplates(patterns ...string) (*template.Template, error) { 103 | tmpl := template.New("") 104 | // adding the embed function for layouts 105 | tmpl.Funcs(template.FuncMap{ 106 | "embed": func(name string, data any) template.HTML { 107 | var out strings.Builder 108 | if err := tmpl.ExecuteTemplate(&out, name, data); err != nil { 109 | log.Println(err) 110 | } 111 | return template.HTML(out.String()) 112 | }, 113 | }) 114 | 115 | parsedTmpl, err := tmpl.ParseFS(templateFS, patterns...) 116 | tmpl = parsedTmpl 117 | 118 | return tmpl, err 119 | } 120 | 121 | func LoadTemplates(patterns ...string) *template.Template { 122 | return template.Must(parseTemplates(patterns...)) 123 | } 124 | {{- end -}} 125 | {{- if and .Web.IsEcho .Render.IsSeperate -}} 126 | //go:build !dev 127 | // +build !dev 128 | 129 | package main 130 | 131 | import ( 132 | "embed" 133 | "net/http" 134 | 135 | "github.com/labstack/echo/v4" 136 | "github.com/labstack/echo/v4/middleware" 137 | ) 138 | 139 | //go:embed web/dist/* 140 | var web embed.FS 141 | 142 | func ServeStatic(e *echo.Echo) { 143 | const ( 144 | root = "web/dist" 145 | index = "index.html" 146 | ) 147 | 148 | e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ 149 | Root: root, 150 | Index: index, 151 | HTML5: true, 152 | Filesystem: http.FS(web), 153 | })) 154 | } 155 | {{- else if and .Web.IsFiber .Render.IsSeperate -}} 156 | //go:build !dev 157 | // +build !dev 158 | 159 | package main 160 | 161 | import ( 162 | "embed" 163 | "io/fs" 164 | "log" 165 | "net/http" 166 | 167 | "github.com/gofiber/fiber/v2" 168 | "github.com/gofiber/fiber/v2/middleware/filesystem" 169 | ) 170 | 171 | //go:embed web/dist/* 172 | var web embed.FS 173 | 174 | func ServeStatic(app *fiber.App) { 175 | const ( 176 | root = "web/dist" 177 | index = "index.html" 178 | fallback = "index.html" 179 | ) 180 | 181 | subFS, err := fs.Sub(web, root) 182 | if err != nil { 183 | log.Fatal(err) 184 | } 185 | 186 | app.Use(filesystem.New(filesystem.Config{ 187 | Root: http.FS(subFS), 188 | Browse: false, 189 | Index: index, 190 | NotFoundFile: fallback, 191 | })) 192 | } 193 | {{- else if and .Web.IsChi .Render.IsSeperate -}} 194 | //go:build !dev 195 | // +build !dev 196 | 197 | package main 198 | 199 | import ( 200 | "embed" 201 | "io/fs" 202 | "log" 203 | "net/http" 204 | "strings" 205 | 206 | "github.com/go-chi/chi/v5" 207 | ) 208 | 209 | //go:embed web/dist/* 210 | var web embed.FS 211 | 212 | func ServeStatic(mux *chi.Mux) { 213 | const ( 214 | root = "web/dist" 215 | index = "index.html" 216 | fallback = "index.html" 217 | ) 218 | 219 | subFS, err := fs.Sub(web, root) 220 | if err != nil { 221 | log.Fatal(err) 222 | } 223 | 224 | fs := http.FileServer(http.FS(subFS)) 225 | 226 | mux.Handle("/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 227 | path := strings.TrimPrefix(r.URL.Path, "/") 228 | 229 | if len(path) == 0 { 230 | path = index 231 | } 232 | 233 | // Check if the requested file exists 234 | _, err := subFS.Open(path) 235 | if err != nil { 236 | // If not found, serve index.html (for client-side routing) 237 | http.ServeFileFS(w, r, subFS, fallback) 238 | return 239 | } 240 | 241 | fs.ServeHTTP(w, r) 242 | })) 243 | } 244 | {{- end -}} -------------------------------------------------------------------------------- /util/rawdata_util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/yosssi/gohtml" 8 | ) 9 | 10 | const ( 11 | basicHomeBodyExampleHTML = ` 12 | 13 |
14 |

{{ .Ctx.Title }}

15 | 21 |

{{ .Ctx.Desc }}

22 |
23 | ` 24 | tailwindHomeBodyExampleHTML = ` 25 | 26 |
27 |

28 | {{ .Ctx.Title }} 29 |

30 | 36 |

{{ .Ctx.Desc }}

37 |
38 | ` 39 | 40 | basicErrorBodyExampleHTML = ` 41 | 42 |

{{ .Ctx.FullError }}

43 | ` 44 | tailwindErrorBodyExampleHTML = ` 45 | 46 |

{{ .Ctx.FullError }}

47 | ` 48 | ) 49 | 50 | func generatePageContent(page string, cfg StackConfig) []byte { 51 | var result string 52 | 53 | switch page { 54 | case "Home.html": 55 | result = processRawHomePageData(cfg) 56 | case "Error.html": 57 | result = processRawErrorPageData(cfg) 58 | case "Root.html": 59 | result = processRootLayoutPageData(cfg) 60 | case "instruction.md": 61 | result = generateInstruction() 62 | } 63 | 64 | return []byte(gohtml.Format(result)) 65 | } 66 | 67 | func processRootLayoutPageData(cfg StackConfig) string { 68 | var ( 69 | bodyClass string 70 | embedFn string 71 | ) 72 | if cfg.WebFramework == "Fiber" { 73 | embedFn = "embed" 74 | } else if cfg.WebFramework == "Chi" { 75 | embedFn = "embed .Page ." 76 | } 77 | if strings.HasPrefix(cfg.CssStrategy, "Tailwind") { 78 | bodyClass = "flex items-center justify-center" 79 | } else { 80 | bodyClass = "container" 81 | } 82 | 83 | rootHTML := fmt.Sprintf(` 84 | 85 | 86 | 87 | 88 | %s 89 | 90 | {{ if .IsDev }} 91 | 92 | {{ end }} 93 | %s 94 | 95 | {{ .Ctx.Title }} 96 | 97 | 98 | {{ %s }} 99 | 100 | `, 101 | generateHeadStyles(cfg), 102 | generateHeadScripts(cfg), 103 | bodyClass, 104 | embedFn, 105 | ) 106 | 107 | return rootHTML 108 | } 109 | 110 | func processRawHomePageData(cfg StackConfig) string { 111 | if cfg.WebFramework == "Fiber" || cfg.WebFramework == "Chi" { 112 | return removeLinesStartEnd(generateHomeHTMLBody(cfg), 2, 1) 113 | } 114 | 115 | homeHTML := fmt.Sprintf(` 116 | 117 | 118 | 119 | 120 | %s 121 | 122 | {{ if .IsDev }} 123 | 124 | {{ end }} 125 | %s 126 | 127 | {{ .Ctx.Title }} 128 | 129 | 130 | %s 131 | `, 132 | generateHeadStyles(cfg), 133 | generateHeadScripts(cfg), 134 | generateHomeHTMLBody(cfg), 135 | ) 136 | 137 | return homeHTML 138 | } 139 | 140 | func processRawErrorPageData(cfg StackConfig) string { 141 | if cfg.WebFramework == "Fiber" || cfg.WebFramework == "Chi" { 142 | return removeLinesStartEnd(generateErrorHTMLBody(cfg), 2, 1) 143 | } 144 | 145 | errorHTML := fmt.Sprintf(` 146 | 147 | 148 | 149 | 150 | %s 151 | 152 | {{ if .IsDev }} 153 | 154 | {{ end }} 155 | 156 | {{ .Ctx.Title }} 157 | 158 | 159 | %s 160 | `, 161 | generateHeadStyles(cfg), 162 | generateErrorHTMLBody(cfg), 163 | ) 164 | 165 | return errorHTML 166 | } 167 | 168 | func generateHomeHTMLBody(cfg StackConfig) string { 169 | if strings.HasPrefix(cfg.CssStrategy, "Tailwind") { 170 | return tailwindHomeBodyExampleHTML 171 | } 172 | return basicHomeBodyExampleHTML 173 | } 174 | 175 | func generateErrorHTMLBody(cfg StackConfig) string { 176 | if strings.HasPrefix(cfg.CssStrategy, "Tailwind") { 177 | return tailwindErrorBodyExampleHTML 178 | } 179 | return basicErrorBodyExampleHTML 180 | } 181 | 182 | func generateHeadScripts(cfg StackConfig) string { 183 | scripts := []string{""} 184 | 185 | if contains(cfg.ExtraOpts, "HTMX") { 186 | scripts = append(scripts, ``) 187 | } 188 | if cfg.UILibrary == "Preline" { 189 | scripts = append(scripts, ``) 190 | } 191 | if len(scripts) == 1 { 192 | return "" 193 | } 194 | 195 | return strings.Join(scripts, "\n") 196 | } 197 | 198 | func generateHeadStyles(StackConfig) string { 199 | styles := []string{ 200 | "", 201 | ``, 202 | } 203 | 204 | return strings.Join(styles, "\n") 205 | } 206 | 207 | func generateInstruction() string { 208 | return ` 209 | # Instructions 210 | After building your frontend, copy the static files in this directory. 211 | 212 | -> web/dist/...files 213 | 214 | For more info visit -> https://github.com/nilotpaul/gospur/blob/main/docs/go-seperate-client.md 215 | **You can delete this file later.** 216 | ` 217 | } 218 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "bytes" 7 | "compress/gzip" 8 | "fmt" 9 | "io" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | 15 | "github.com/nilotpaul/gospur/config" 16 | ) 17 | 18 | const maxNestingDepth = 3 19 | 20 | // SanitizeDirPath takes a `path` and checks if the given 21 | // project path is valid or not. 22 | func ValidateDirPath(path string) (string, error) { 23 | dir := filepath.Clean(path) 24 | 25 | // Check for invalid paths like `/../`. 26 | if strings.Contains(dir, "..") { 27 | return "", fmt.Errorf("invalid directory path: '%s' contains '..'", dir) 28 | } 29 | 30 | // Check the nesting depth. 31 | depth := strings.Count(dir, string(filepath.Separator)) 32 | // Avoid deep nesting for paths more than 3 depth. 33 | if depth > maxNestingDepth { 34 | return "", fmt.Errorf("invalid directory path: exceeds maximum allowed depth of %d", maxNestingDepth) 35 | 36 | } 37 | 38 | return dir, nil 39 | } 40 | 41 | // CreateTargetDir takes a `path` and `strict`, 42 | // 43 | // In strict mode, it'll check if the directory is empty or not 44 | // if the dir already exists. If the dir doesn't exist it'll create one. 45 | // 46 | // If not in strict mode, it'll ignore the directory status and 47 | // create the necessary dir(s). 48 | func CreateTargetDir(path string, strict bool) error { 49 | if strict { 50 | _, err := doesTargetDirExistAndIsEmpty(path) 51 | if err != nil && !os.IsNotExist(err) { 52 | return err 53 | } 54 | } 55 | 56 | // Target dir doesn't exist, we need to create it. 57 | if err := os.MkdirAll(path, os.ModePerm); err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func MakeProjectCtx(cfg StackConfig, modPath string) map[string]any { 65 | return map[string]any{ 66 | "ModPath": modPath, 67 | "IsLinux": strings.Split(runtime.GOOS, "/")[0] == "linux", 68 | "Web": map[string]bool{ 69 | "IsEcho": cfg.WebFramework == "Echo", 70 | "IsFiber": cfg.WebFramework == "Fiber", 71 | "IsChi": cfg.WebFramework == "Chi", 72 | }, 73 | "UI": map[string]bool{ 74 | // CSS Strategy 75 | "HasTailwind": strings.HasPrefix(cfg.CssStrategy, "Tailwind"), 76 | "HasTailwind4": cfg.CssStrategy == "Tailwind4", 77 | "HasTailwind3": cfg.CssStrategy == "Tailwind3", 78 | 79 | // CSS Library 80 | "HasPreline": cfg.UILibrary == "Preline", 81 | "HasDaisy": cfg.UILibrary == "DaisyUI", 82 | }, 83 | "Render": map[string]bool{ 84 | "IsTemplates": cfg.RenderingStrategy == "Templates", 85 | "IsSeperate": cfg.RenderingStrategy == "Seperate", 86 | }, 87 | "Extras": map[string]bool{ 88 | "HasHTMX": contains(cfg.ExtraOpts, "HTMX"), 89 | }, 90 | } 91 | } 92 | 93 | // AutoDetectBinaryURL loops over assets (binary links) and returns one 94 | // compatible with the current system. 95 | func FindMatchingBinary(names []string, os string, arch string) string { 96 | os, arch = mapRuntimeOSAndArch(os, arch) 97 | expected := fmt.Sprintf("gospur_%s_%s", os, arch) 98 | 99 | for _, name := range names { 100 | if strings.Contains(name, expected) { 101 | return name 102 | } 103 | } 104 | 105 | return "" 106 | } 107 | 108 | func GetRenderingOpts(actual bool) []string { 109 | opts := make([]string, len(config.RenderingStrategy)) 110 | idx := 0 111 | 112 | for name, actualName := range config.RenderingStrategy { 113 | if actual { 114 | opts[idx] = actualName 115 | } else { 116 | opts[idx] = name 117 | } 118 | idx++ 119 | } 120 | 121 | return opts 122 | } 123 | 124 | func GetMapKeys[K comparable, V any](m map[K]V) []K { 125 | keys := make([]K, 0) 126 | for k := range m { 127 | keys = append(keys, k) 128 | } 129 | 130 | return keys 131 | } 132 | 133 | func uncompress(src io.Reader, url string) (io.Reader, error) { 134 | if strings.HasSuffix(url, ".zip") { 135 | buf, err := io.ReadAll(src) 136 | if err != nil { 137 | return nil, fmt.Errorf("failed to read the release .zip file: %v", err) 138 | } 139 | 140 | r := bytes.NewReader(buf) 141 | z, err := zip.NewReader(r, r.Size()) 142 | if err != nil { 143 | return nil, fmt.Errorf("failed to uncompress the .zip file: %v", err) 144 | } 145 | 146 | for _, file := range z.File { 147 | _, name := filepath.Split(file.Name) 148 | if !file.FileInfo().IsDir() && matchBinaryFile(name) { 149 | return file.Open() 150 | } 151 | } 152 | } else if strings.HasSuffix(url, ".tar.gz") { 153 | gz, err := gzip.NewReader(src) 154 | if err != nil { 155 | return nil, fmt.Errorf("failed to uncompress the .tar.gz file: %v", err) 156 | } 157 | 158 | return unarchiveTarGZ(gz) 159 | } 160 | 161 | return nil, fmt.Errorf("given file is not .tar.gz or .zip format") 162 | } 163 | 164 | func unarchiveTarGZ(src io.Reader) (io.Reader, error) { 165 | t := tar.NewReader(src) 166 | for { 167 | h, err := t.Next() 168 | if err == io.EOF { 169 | break 170 | } 171 | if err != nil { 172 | return nil, fmt.Errorf("failed to unarchive .tar.gz file: %v", err) 173 | } 174 | _, name := filepath.Split(h.Name) 175 | if matchBinaryFile(name) { 176 | return t, nil 177 | } 178 | } 179 | 180 | return nil, fmt.Errorf("binary not found after uncompressing") 181 | } 182 | 183 | // doesTargetDirExistAndIsEmpty takes a `target` path, if it's 184 | // not a directory, not empty or doesn't exist then it'll return 185 | // false and an error, otherwise true and nil error. 186 | func doesTargetDirExistAndIsEmpty(target string) (bool, error) { 187 | file, err := os.Stat(target) 188 | if err != nil { 189 | return false, err 190 | } 191 | if !file.IsDir() { 192 | return false, fmt.Errorf("'%s' is not a directory", target) 193 | } 194 | 195 | entires, err := os.ReadDir(target) 196 | if err != nil { 197 | return false, err 198 | } 199 | 200 | if len(entires) != 0 { 201 | return false, fmt.Errorf("'%s' is not empty", target) 202 | } 203 | 204 | return true, nil 205 | } 206 | 207 | // Map Go's OS to GoReleaser's naming convention. 208 | func mapRuntimeOSAndArch(os string, arch string) (mappedOS string, mappedArch string) { 209 | switch os { 210 | case "darwin": 211 | mappedOS = "Darwin" 212 | case "linux": 213 | mappedOS = "Linux" 214 | case "windows": 215 | mappedOS = "Windows" 216 | default: 217 | mappedOS = os 218 | } 219 | 220 | switch arch { 221 | case "amd64": 222 | mappedArch = "x86_64" 223 | case "386": 224 | mappedArch = "i386" 225 | case "arm64": 226 | mappedArch = "arm64" 227 | default: 228 | mappedArch = arch 229 | } 230 | 231 | return mappedOS, mappedArch 232 | } 233 | 234 | // matchBinaryFile checks returns true if the binary name is correct. 235 | func matchBinaryFile(name string) bool { 236 | switch name { 237 | case config.WinBinaryName: 238 | return true 239 | case config.OtherBinaryName: 240 | return true 241 | default: 242 | return false 243 | } 244 | } 245 | 246 | // contains checks if a slice of string contains the given item. 247 | func contains(slice []string, item string) bool { 248 | for _, v := range slice { 249 | if v == item { 250 | return true 251 | } 252 | } 253 | return false 254 | } 255 | 256 | func removeLinesStartEnd(s string, start, end int) string { 257 | lines := strings.Split(s, "\n") 258 | if len(lines) > 2 { 259 | lines = lines[start : len(lines)-end] 260 | } 261 | 262 | return strings.Join(lines, "\n") 263 | } 264 | -------------------------------------------------------------------------------- /util/command_util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/inconshreveable/go-update" 15 | "github.com/manifoldco/promptui" 16 | "github.com/nilotpaul/gospur/config" 17 | "github.com/nilotpaul/gospur/ui" 18 | ) 19 | 20 | // StackConfig represents a final stack configuration 21 | // based on which project files will be made. 22 | type StackConfig struct { 23 | // Echo, Fiber, etc... 24 | WebFramework string 25 | 26 | // CssStrategy can be tailwind, vanilla, etc. 27 | CssStrategy string 28 | // UI Library is pre-made styled libs like Preline. 29 | UILibrary string 30 | 31 | // RenderingStrategy defines how HTML is rendered. 32 | // Eg. templates, templ, seperate client. 33 | RenderingStrategy string 34 | 35 | // Flags Only 36 | // Extras are extra add-ons like css lib, HTMX etc. 37 | ExtraOpts []string 38 | } 39 | 40 | // ProjectPath represents destination or location 41 | // where user want their project to be created. 42 | type ProjectPath struct { 43 | // FullPath is the absolute path to the project directory. 44 | FullPath string 45 | 46 | // Path is the relative path to the project directory. 47 | Path string 48 | } 49 | 50 | type GitHubReleaseResponse struct { 51 | Version string `json:"tag_name"` 52 | Assets []struct { 53 | ID int64 `json:"id"` 54 | Name string `json:"name"` 55 | Size int64 `json:"size"` 56 | BrowserDownloadURL string `json:"browser_download_url"` 57 | } `json:"assets"` 58 | } 59 | 60 | // GetStackConfig will give a series of prompts 61 | // to the user to configure their project stack. 62 | func GetStackConfig(cfg *StackConfig) error { 63 | // Framework options 64 | if len(cfg.WebFramework) == 0 { 65 | frameworkPrompt := promptui.Select{ 66 | Label: "Choose a web framework", 67 | Items: config.WebFrameworkOpts, 68 | } 69 | _, framework, err := frameworkPrompt.Run() 70 | if err != nil { 71 | return fmt.Errorf("failed to select web framework") 72 | } 73 | cfg.WebFramework = framework 74 | } 75 | // Rendering Strategy Options 76 | if len(cfg.RenderingStrategy) == 0 { 77 | renderingStratPrompt := promptui.Select{ 78 | Label: "Choose a Rendering Strategy", 79 | Items: GetRenderingOpts(false), 80 | } 81 | _, opt, err := renderingStratPrompt.Run() 82 | if err != nil { 83 | return fmt.Errorf("failed to select Rendering Strategy") 84 | } 85 | if v, ok := config.RenderingStrategy[opt]; ok { 86 | cfg.RenderingStrategy = v 87 | } 88 | } 89 | // CSS Strategy 90 | if len(cfg.CssStrategy) == 0 && cfg.RenderingStrategy == "Templates" { 91 | extraPrompt := promptui.Select{ 92 | Label: "Choose a CSS Strategy", 93 | Items: config.CssStrategyOpts, 94 | } 95 | _, css, err := extraPrompt.Run() 96 | if err != nil { 97 | return fmt.Errorf("failed to select CSS Strategy") 98 | } 99 | cfg.CssStrategy = css 100 | } 101 | // UI Library Options 102 | if len(cfg.UILibrary) == 0 && cfg.RenderingStrategy == "Templates" { 103 | // Filtering the opts for UI Libs based on the css strategy chosen. 104 | filteredOpts := make([]string, 0) 105 | for lib, deps := range config.UILibraryOpts { 106 | if len(deps) == 0 { 107 | filteredOpts = append(filteredOpts, lib) 108 | continue 109 | } 110 | if contains(deps, cfg.CssStrategy) { 111 | filteredOpts = append(filteredOpts, lib) 112 | } 113 | } 114 | 115 | // Only ask anything if we have a compatible UI Lib for 116 | // the chosen CSS Strategy. 117 | if len(filteredOpts) != 0 { 118 | // Asking for UI Lib if we've any filtered opts. 119 | uiLibPrompt := promptui.Select{ 120 | Label: "Choose a UI Library", 121 | Items: filteredOpts, 122 | } 123 | _, uiLib, err := uiLibPrompt.Run() 124 | if err != nil { 125 | return fmt.Errorf("failed to select UI Library") 126 | } 127 | cfg.UILibrary = uiLib 128 | } 129 | } 130 | 131 | return nil 132 | } 133 | 134 | // GetGoModulePath will give a input prompt to the user 135 | // for them to enter a go mod path. 136 | func GetGoModulePath() (string, error) { 137 | pathPrompt := promptui.Prompt{ 138 | Label: "Enter go mod path (eg. github.com/username/repo)", 139 | Validate: validateGoModPath, 140 | } 141 | path, err := pathPrompt.Run() 142 | if err != nil { 143 | return "", fmt.Errorf("error getting the mod path %v", err) 144 | } 145 | 146 | return path, nil 147 | } 148 | 149 | // RunGoModInit takes the full project path and a name. 150 | // It changes the cwd to the given path and run go mod init 151 | // with the given name. 152 | func RunGoModInit(fullProjectPath, name string) error { 153 | // Change the current working directory to the project directory 154 | if err := os.Chdir(fullProjectPath); err != nil { 155 | return fmt.Errorf("failed to change to project directory: %v", err) 156 | } 157 | 158 | cmd := exec.Command("go", "mod", "init", name) 159 | return cmd.Run() 160 | } 161 | 162 | // GetProjectPath takes a slice of args (all provided args), validates 163 | // and determines the absolute project path depending on the cwd. 164 | // If no args provided, we fallback to the default set path 'gospur'. 165 | func GetProjectPath(args []string) (*ProjectPath, error) { 166 | targetPath := "gospur" 167 | 168 | if len(args) > 0 { 169 | // Santize the given path. 170 | finalPath, err := ValidateDirPath(args[0]) 171 | if err != nil { 172 | return nil, err 173 | } 174 | // Now it's safe to use the `targetPath`. 175 | targetPath = finalPath 176 | } 177 | 178 | cwd, err := os.Getwd() 179 | if err != nil { 180 | return nil, fmt.Errorf("error getting the current working directory %v", err) 181 | } 182 | 183 | fullPath := filepath.Join(cwd, targetPath) 184 | 185 | return &ProjectPath{FullPath: fullPath, Path: targetPath}, nil 186 | } 187 | 188 | func FetchRelease(ctx context.Context, v ...string) (GitHubReleaseResponse, error) { 189 | var ( 190 | data GitHubReleaseResponse 191 | givenVersion = "latest" 192 | releaseUrl = fmt.Sprintf(config.GitHubReleaseAPIURL+"/%s", givenVersion) 193 | ) 194 | if len(v) > 0 && v[0] != "latest" { 195 | releaseUrl = fmt.Sprintf(config.GitHubReleaseAPIURL+"/tags/%s", v[0]) 196 | } 197 | 198 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseUrl, nil) 199 | if err != nil { 200 | return data, err 201 | } 202 | 203 | client := &http.Client{} 204 | res, err := client.Do(req) 205 | if err != nil { 206 | if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { 207 | return data, fmt.Errorf("request took too long or canceled") 208 | } 209 | 210 | return data, err 211 | } 212 | if res.StatusCode == http.StatusNotFound { 213 | return data, fmt.Errorf("version not found") 214 | } 215 | defer res.Body.Close() 216 | 217 | if err := json.NewDecoder(res.Body).Decode(&data); err != nil { 218 | return data, err 219 | } 220 | 221 | return data, nil 222 | } 223 | 224 | // HandleUpdateCLI updates the cli to a new version, handling the pending 225 | // states and clean up. 226 | func HandleUpdateCLI(url string, exePath string) error { 227 | var ( 228 | errChan = make(chan error, 1) 229 | s = ui.NewSpinner("updating...") 230 | ) 231 | 232 | s.Start() 233 | defer func() { 234 | close(errChan) 235 | s.Stop() 236 | fmt.Printf("%s", "\n") 237 | }() 238 | 239 | go func() { 240 | errChan <- doUpdate(url, exePath) 241 | }() 242 | 243 | err := <-errChan 244 | return err 245 | } 246 | 247 | // HandleGetRelease handles gets the latest release, handles pending states and clean up. 248 | func HandleGetRelease(ctx context.Context) (GitHubReleaseResponse, error) { 249 | var ( 250 | releaseChan = make(chan GitHubReleaseResponse, 1) 251 | errChan = make(chan error, 1) 252 | 253 | s = ui.NewSpinner("getting the latest version...") 254 | ) 255 | 256 | s.Start() 257 | defer func() { 258 | close(releaseChan) 259 | close(errChan) 260 | s.Stop() 261 | fmt.Printf("%s", "\n") 262 | }() 263 | 264 | go func() { 265 | // Fetch the latest release from github. 266 | release, err := FetchRelease(ctx, "latest") 267 | 268 | releaseChan <- release 269 | errChan <- err 270 | }() 271 | 272 | release := <-releaseChan 273 | err := <-errChan 274 | 275 | return release, err 276 | } 277 | 278 | func PrintSuccessMsg(path string, cfg StackConfig) { 279 | fmt.Println(config.SuccessMsg("\nProject Created! 🎉\n")) 280 | fmt.Println(config.NormalMsg("Please Run:")) 281 | 282 | // Post installation instructions 283 | if path == "." { 284 | fmt.Println(config.FaintMsg(` 285 | go install github.com/bokwoon95/wgo@latest 286 | go mod tidy 287 | npm install 288 | `)) 289 | } else if cfg.RenderingStrategy == "Seperate" { 290 | fmt.Println(config.FaintMsg(fmt.Sprintf(` 291 | cd %s 292 | go install github.com/bokwoon95/wgo@latest 293 | go mod tidy 294 | `, path))) 295 | } else { 296 | fmt.Println(config.FaintMsg(fmt.Sprintf(` 297 | cd %s 298 | go install github.com/bokwoon95/wgo@latest 299 | go mod tidy 300 | npm install 301 | `, path))) 302 | } 303 | 304 | } 305 | 306 | func doUpdate(url string, targetPath string) error { 307 | resp, err := http.Get(url) 308 | if err != nil { 309 | return err 310 | } 311 | defer resp.Body.Close() 312 | 313 | binary, err := uncompress(resp.Body, url) 314 | if err != nil { 315 | return err 316 | } 317 | 318 | return update.Apply(binary, update.Options{ 319 | TargetPath: targetPath, 320 | TargetMode: os.ModePerm, 321 | }) 322 | } 323 | 324 | func validateGoModPath(path string) error { 325 | if len(path) < 3 { 326 | return fmt.Errorf("path cannot be less than 3 character(s)") 327 | } 328 | // Starts with https:// 329 | if strings.HasPrefix(path, "https://") { 330 | return fmt.Errorf("invalid path '%s', should not contain https", path) 331 | } 332 | // Contains any of these -> :*?| 333 | if strings.ContainsAny(path, " :*?|") { 334 | return fmt.Errorf("invalid path '%s', contains reserved characters", path) 335 | } 336 | // Length exceedes 255 character(s) 337 | if len(path) > 255 { 338 | return fmt.Errorf("exceeded maximum length") 339 | } 340 | 341 | return nil 342 | } 343 | -------------------------------------------------------------------------------- /util/template_util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "html/template" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/nilotpaul/gospur/config" 13 | tmpls "github.com/nilotpaul/gospur/template" 14 | ) 15 | 16 | // processTemplate represents a template that needs to be processed(parsed) 17 | // and writen to the specified target path in the project directory. 18 | type processTemplate struct { 19 | targetFilePath string 20 | template *template.Template 21 | } 22 | 23 | // CreateProject takes a `targetDir` and any optional data. 24 | // It creates the necessary folders and files for the entire project. 25 | func CreateProject(targetDir string, cfg StackConfig, data interface{}) error { 26 | // Ranging over files in base dir which doesn't depend on `StackConfig` 27 | for targetPath, templatePath := range preprocessBaseFiles(cfg) { 28 | // Getting the embeded folder containing all base template files. 29 | tmplFS := tmpls.GetBaseFiles() 30 | 31 | // `targetFilePath` is the final path where the file will be stored. 32 | // It's joined with the (project or target) dir. 33 | targetFilePath := filepath.Join(targetDir, targetPath) 34 | 35 | // Parsing the raw tempate to get the processed template which will contain 36 | // the `targetFilePath`(location where the target file will be written) and 37 | // actual `template` itself. 38 | processedTmpl, err := parseTemplate(targetFilePath, templatePath, tmplFS) 39 | if err != nil { 40 | return fmt.Errorf("template Parsing Error (pls report): %v", err) 41 | } 42 | 43 | // Creating the file with the parsed template. 44 | err = createFileFromTemplate( 45 | processedTmpl.targetFilePath, 46 | processedTmpl.template, 47 | data, 48 | ) 49 | if err != nil { 50 | return fmt.Errorf( 51 | "failed to create file -> '%s' due to %v", 52 | processedTmpl.targetFilePath, 53 | err, 54 | ) 55 | } 56 | } 57 | 58 | // Ranging over files in API dir which depend on `StackConfig`. 59 | for targetPath, templatePath := range preprocessAPIFiles(cfg) { 60 | // Getting the embeded folder containing all API template files. 61 | tmplFS := tmpls.GetAPIFiles() 62 | 63 | // `targetFilePath` is the final path where the file will be stored. 64 | // It's joined with the project/target dir. 65 | targetFilePath := filepath.Join(targetDir, targetPath) 66 | 67 | // Parsing the raw tempate to get the processed template which will contain 68 | // the `targetFilePath`(location where the target file will be written) and 69 | // actual `template` itself. 70 | processedTmpl, err := parseTemplate(targetFilePath, templatePath, tmplFS) 71 | if err != nil { 72 | return fmt.Errorf("template Parsing Error (pls report): %v", err) 73 | } 74 | 75 | // Creating the file with the parsed template. 76 | err = createFileFromTemplate( 77 | processedTmpl.targetFilePath, 78 | processedTmpl.template, 79 | data, 80 | ) 81 | if err != nil { 82 | return fmt.Errorf( 83 | "failed to create file -> '%s' due to %v", 84 | processedTmpl.targetFilePath, 85 | err, 86 | ) 87 | } 88 | } 89 | 90 | // Ranging over files in page dir which depend on `StackConfig`. 91 | // 92 | // These needs to be processed seperately as it needs to be written 93 | // as template files itself, thus parsing isn't required. 94 | for targetPath := range preprocessPageFiles(cfg) { 95 | var ( 96 | paths = strings.Split(targetPath, "/") 97 | name = paths[len(paths)-1] 98 | ) 99 | 100 | // `targetFilePath` is the final path where the file will be stored. 101 | // It's joined with the project/target dir. 102 | targetFilePath := filepath.Join(targetDir, targetPath) 103 | 104 | // Generating the page content with `StackConfig`. 105 | fileBytes := generatePageContent(name, cfg) 106 | 107 | // Creating the file with the raw template. 108 | if err := writeRawTemplateFile(targetFilePath, fileBytes); err != nil { 109 | return fmt.Errorf( 110 | "failed to create file -> '%s' due to %v", 111 | targetFilePath, 112 | err, 113 | ) 114 | } 115 | } 116 | 117 | // Create an example public asset if rendering is not seperate. 118 | if cfg.RenderingStrategy != "Seperate" { 119 | if err := createExamplePublicAsset(targetDir); err != nil { 120 | return fmt.Errorf("failed to create the public directory %v", err) 121 | } 122 | } 123 | if cfg.RenderingStrategy == "Seperate" { 124 | if err := createGitKeepFile(targetDir); err != nil { 125 | return fmt.Errorf("failed to create .gitkeep inside web dir %v", err) 126 | } 127 | } 128 | 129 | return nil 130 | } 131 | 132 | func ValidateStackConfig(cfg StackConfig) error { 133 | var errors []string 134 | 135 | if !matchFrameworkOpt(cfg.WebFramework) { 136 | errors = append(errors, "Invalid Web Framework") 137 | } 138 | if !matchRenderOpt(cfg.RenderingStrategy) { 139 | errors = append(errors, "Invalid Rendering Strategy") 140 | } 141 | if !matchStylingOpt(cfg.CssStrategy) { 142 | errors = append(errors, "Invalid CSS Strategy") 143 | } 144 | if !matchUIOpt(cfg.UILibrary) { 145 | errors = append(errors, "Invalid UI Library") 146 | } 147 | 148 | for _, opt := range cfg.ExtraOpts { 149 | if !matchExtraOpt(opt) { 150 | errors = append(errors, fmt.Sprintf("Invalid Extra: %s", opt)) 151 | } 152 | } 153 | 154 | if len(errors) > 0 { 155 | return fmt.Errorf("\n%s", strings.Join(errors, "\n")) 156 | } 157 | return nil 158 | } 159 | 160 | // parseTemplate takes `fullWritePath`, template path and template embed. 161 | // 162 | // `fullWritePath` -> has to be joined with the project or targetPath. (eg. gospur/config/env.go) 163 | // `tmplPath` -> path where the template is stored 164 | // `tmplFS` -> template embed FS which contains all template files. 165 | func parseTemplate(fullWritePath, tmplPath string, tmplFS embed.FS) (*processTemplate, error) { 166 | fileBytes, err := tmplFS.ReadFile(tmplPath) 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | // Parsing the tmpl bytes(file contents) to get the actual template. 172 | tmpl, err := template.New(filepath.Base(tmplPath)).Parse(string(fileBytes)) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | return &processTemplate{targetFilePath: fullWritePath, template: tmpl}, nil 178 | } 179 | 180 | // createFileFromTemplate writes the output of a parsed template to a specified file path, 181 | // creating directories as needed. 182 | // 183 | // `fullWritePath`: The full path where the file will be created (e.g., "project/config/env.go"). 184 | // `tmpl`: The parsed template to execute and write to the file. 185 | // `data`: Dynamic data for the template; use `nil` if not required. 186 | func createFileFromTemplate(fullWritePath string, tmpl *template.Template, data interface{}) error { 187 | // Create parent directories for the target file. 188 | // Here second arg of `CreateTargetDir` is false which depicts write even 189 | // if the directory is not empty. 190 | if err := CreateTargetDir(filepath.Dir(fullWritePath), false); err != nil { 191 | return err 192 | } 193 | 194 | // Create the file in the target file path. 195 | destFile, err := os.Create(fullWritePath) 196 | if err != nil { 197 | return err 198 | } 199 | defer destFile.Close() 200 | 201 | // Execute the template and write the output to the file. 202 | if err := tmpl.Execute(destFile, data); err != nil { 203 | return err 204 | } 205 | 206 | return nil 207 | } 208 | 209 | // writeRawTemplateFile writes the raw contents of a template file directly to a specified path. 210 | // 211 | // `fullWritePath`: The full path where the file will be created (e.g., "project/templates/index.html"). 212 | // `templatePath`: The path of the static template file within the embedded filesystem. 213 | // `tmplFS`: The embedded filesystem containing the template files. 214 | func writeRawTemplateFile(fullWritePath string, bytes []byte) error { 215 | if err := CreateTargetDir(filepath.Dir(fullWritePath), false); err != nil { 216 | return err 217 | } 218 | 219 | // Write the file directly 220 | return os.WriteFile(fullWritePath, bytes, fs.ModePerm) 221 | } 222 | 223 | // createExamplePublicAsset takes a project dir path and creates a example public 224 | // asset in the created project template. 225 | func createExamplePublicAsset(projectDir string) error { 226 | fullFilePath := filepath.Join(projectDir, "public", "golang.jpg") 227 | assetBytes := tmpls.GetGolangImage() 228 | 229 | if err := CreateTargetDir(filepath.Dir(fullFilePath), false); err != nil { 230 | return err 231 | } 232 | 233 | // Create the file in the public folder in the project dir. 234 | destFile, err := os.Create(fullFilePath) 235 | if err != nil { 236 | return err 237 | } 238 | defer destFile.Close() 239 | 240 | // Write the (file contents) -> []byte to the created file. 241 | if _, err := destFile.Write(assetBytes); err != nil { 242 | return err 243 | } 244 | 245 | return nil 246 | } 247 | 248 | func createGitKeepFile(projectDir string) error { 249 | fullFilePath := filepath.Join(projectDir, "web", ".gitkeep") 250 | if err := CreateTargetDir(filepath.Dir(fullFilePath), false); err != nil { 251 | return err 252 | } 253 | 254 | file, err := os.Create(fullFilePath) 255 | if err != nil { 256 | return err 257 | } 258 | defer file.Close() 259 | return nil 260 | } 261 | 262 | // preprocessAPIFiles takes `StackConfig` and processes the Base Files to 263 | // strip, exclude any unnecessary files or configuration based on the `StackConfig`. 264 | func preprocessAPIFiles(cfg StackConfig) config.ProjectFiles { 265 | parsedApiFiles := make(config.ProjectFiles, 0) 266 | for target, paths := range config.ProjectAPIFiles { 267 | var templatePath string 268 | for _, path := range paths { 269 | parts := strings.Split(path, ".") 270 | frameworkFromFileName := parts[len(parts)-2] 271 | if strings.ToLower(cfg.WebFramework) == frameworkFromFileName { 272 | templatePath = path 273 | } 274 | } 275 | parsedApiFiles[target] = templatePath 276 | } 277 | 278 | return parsedApiFiles 279 | } 280 | 281 | // preprocessAPIFiles takes `StackConfig` and processes the API Files to 282 | // strip, exclude any unnecessary files or configuration based on the `StackConfig`. 283 | func preprocessBaseFiles(cfg StackConfig) config.ProjectFiles { 284 | parsedBaseFiles := make(config.ProjectFiles, 0) 285 | for target, path := range config.ProjectBaseFiles { 286 | if skip := skipProjectfiles(target, cfg); skip { 287 | continue 288 | } 289 | parsedBaseFiles[target] = path 290 | } 291 | 292 | return parsedBaseFiles 293 | } 294 | 295 | // preprocessAPIFiles takes `StackConfig` and processes the API Files to 296 | // strip, exclude any unnecessary files or configuration based on the `StackConfig`. 297 | func preprocessPageFiles(cfg StackConfig) config.ProjectFiles { 298 | parsedBaseFiles := make(config.ProjectFiles, 0) 299 | for target, path := range config.ProjectPageFiles { 300 | // Skip everything except instruction.md if seperate client is chosen. 301 | if cfg.RenderingStrategy == "Seperate" && !strings.HasSuffix(target, "instruction.md") { 302 | continue 303 | } 304 | // Skip instruction.md if seperate client is not chosen. 305 | if cfg.RenderingStrategy != "Seperate" && strings.HasSuffix(target, "instruction.md") { 306 | continue 307 | } 308 | // Skip layouts dir if not supported. 309 | if strings.HasPrefix(target, "web/layouts") && (cfg.WebFramework != "Fiber" && cfg.WebFramework != "Chi") { 310 | continue 311 | } 312 | parsedBaseFiles[target] = path 313 | } 314 | 315 | return parsedBaseFiles 316 | } 317 | 318 | // skipProjectfiles returns bool indicating whether a project file need to be skipped. 319 | // true -> need to be skipped. 320 | // false -> doesn't need to be skipped. 321 | // 322 | // Info: Should be only valid for Base Files. 323 | func skipProjectfiles(filePath string, cfg StackConfig) bool { 324 | if cfg.RenderingStrategy == "Seperate" && isFrontendFile(filePath) { 325 | return true 326 | } 327 | // Skip tailwind config if tailwind is not selected as a CSS Strategy. 328 | if filePath == "tailwind.config.js" && cfg.CssStrategy != "Tailwind3" { 329 | return true 330 | } 331 | // Skip Dockerfile and dockerignore if not selected in extra options. 332 | if (filePath == "Dockerfile" || filePath == ".dockerignore") && !contains(cfg.ExtraOpts, "Dockerfile") { 333 | return true 334 | } 335 | 336 | return false 337 | } 338 | 339 | func matchFrameworkOpt(v string) bool { 340 | switch v { 341 | case "Echo": 342 | return true 343 | case "Chi": 344 | return true 345 | case "Fiber": 346 | return true 347 | default: 348 | return false 349 | } 350 | } 351 | 352 | func matchRenderOpt(v string) bool { 353 | switch v { 354 | case "Templates": 355 | return true 356 | case "Seperate": 357 | return true 358 | default: 359 | return false 360 | } 361 | } 362 | 363 | func matchStylingOpt(v string) bool { 364 | switch v { 365 | case "Vanilla": 366 | return true 367 | case "Tailwind4": 368 | return true 369 | case "Tailwind3": 370 | return true 371 | case "": 372 | return true 373 | default: 374 | return false 375 | } 376 | } 377 | 378 | func matchUIOpt(v string) bool { 379 | switch v { 380 | case "Preline": 381 | return true 382 | case "DaisyUI": 383 | return true 384 | // Can be empty if not chosen or not compatible 385 | case "": 386 | return true 387 | default: 388 | return false 389 | } 390 | } 391 | 392 | func matchExtraOpt(v string) bool { 393 | switch v { 394 | case "HTMX": 395 | return true 396 | case "Dockerfile": 397 | return true 398 | // Can be empty if not chosen 399 | case "": 400 | return true 401 | default: 402 | return false 403 | } 404 | } 405 | 406 | func isFrontendFile(s string) bool { 407 | return strings.HasSuffix(s, ".js") || 408 | strings.HasSuffix(s, ".json") || 409 | strings.HasSuffix(s, ".css") || 410 | strings.HasPrefix(s, "web/styles") || 411 | strings.HasPrefix(s, "public") 412 | } 413 | --------------------------------------------------------------------------------