├── .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 |
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.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.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.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 |
21 | {{ .Ctx.Desc }}
22 |
36 | {{ .Ctx.Desc }}
37 |