├── go.sum
├── examples
├── embedfs
│ ├── home.html
│ ├── go.mod
│ ├── .gitignore
│ ├── README.md
│ └── main.go
├── render
│ ├── home.html
│ ├── child.html
│ ├── go.sum
│ ├── go.mod
│ ├── .gitignore
│ ├── sidebar.html
│ ├── README.md
│ ├── index.html
│ └── main.go
├── sse
│ ├── go.sum
│ ├── go.mod
│ ├── .gitignore
│ ├── index.html
│ ├── README.md
│ └── sse.go
├── snake
│ ├── go.sum
│ ├── go.mod
│ ├── board.gohtml
│ ├── README.md
│ ├── game.go
│ ├── index.gohtml
│ └── snake.go
├── tiktaktoe
│ ├── go.sum
│ ├── go.mod
│ ├── README.md
│ ├── board.gohtml
│ ├── index.gohtml
│ └── tiktaktoe.go
└── pokedex
│ ├── go.mod
│ ├── .gitignore
│ ├── README.md
│ ├── index.html
│ └── pokedex.go
├── go.mod
├── .gitignore
├── middleware
└── middleware.go
├── .github
├── FUNDING.yml
└── workflows
│ └── golangci-lint.yml
├── LICENSE
├── response.go
├── request.go
├── trigger_test.go
├── htmx.go
├── trigger.go
├── DESIGN_DECISIONS.md
├── LOB.md
├── sse
└── sse.go
├── swap_test.go
├── swap.go
├── htmx_test.go
├── component.go
├── handler.go
├── COMPONENTS.md
└── README.md
/go.sum:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/embedfs/home.html:
--------------------------------------------------------------------------------
1 | {{ .Data.Text }}
--------------------------------------------------------------------------------
/examples/render/home.html:
--------------------------------------------------------------------------------
1 | {{ .Data.Text }}
--------------------------------------------------------------------------------
/examples/render/child.html:
--------------------------------------------------------------------------------
1 |
{{ .Data.Text }}
2 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/donseba/go-htmx
2 |
3 | go 1.22
4 |
--------------------------------------------------------------------------------
/examples/sse/go.sum:
--------------------------------------------------------------------------------
1 | github.com/donseba/go-htmx v1.9.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s=
2 |
--------------------------------------------------------------------------------
/examples/render/go.sum:
--------------------------------------------------------------------------------
1 | github.com/donseba/go-htmx v1.9.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s=
2 |
--------------------------------------------------------------------------------
/examples/snake/go.sum:
--------------------------------------------------------------------------------
1 | github.com/donseba/go-htmx v1.9.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s=
2 |
--------------------------------------------------------------------------------
/examples/tiktaktoe/go.sum:
--------------------------------------------------------------------------------
1 | github.com/donseba/go-htmx v1.9.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s=
2 |
--------------------------------------------------------------------------------
/examples/sse/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/donseba/go-htmx/examples/sse
2 |
3 | go 1.22
4 |
5 | require github.com/donseba/go-htmx v1.9.0
6 |
--------------------------------------------------------------------------------
/examples/snake/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/donseba/go-htmx/examples/snake
2 |
3 | go 1.23
4 |
5 | require github.com/donseba/go-htmx v1.9.0
6 |
--------------------------------------------------------------------------------
/examples/pokedex/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/donseba/go-htmx/examples/pokedex
2 |
3 | go 1.22
4 |
5 | require github.com/donseba/go-htmx v1.9.0
6 |
--------------------------------------------------------------------------------
/examples/render/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/donseba/go-htmx/examples/render
2 |
3 | go 1.22
4 |
5 | require github.com/donseba/go-htmx v1.9.0
6 |
--------------------------------------------------------------------------------
/examples/tiktaktoe/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/donseba/go-htmx/examples/tiktaktoe
2 |
3 | go 1.23
4 |
5 | require github.com/donseba/go-htmx v1.9.0
6 |
--------------------------------------------------------------------------------
/examples/embedfs/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/donseba/go-htmx/examples/embedfs
2 |
3 | go 1.22
4 |
5 | require github.com/donseba/go-htmx v1.11.3
6 |
7 | replace github.com/donseba/go-htmx => ./../../
8 |
--------------------------------------------------------------------------------
/examples/snake/board.gohtml:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{range $i, $row := .Data.game.Board }}
4 |
5 | {{range $j, $cell := $row}}
6 |
7 | {{ $cell }}
8 |
9 | {{end}}
10 |
11 | {{end}}
12 |
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 | vendor/
17 | .idea/
18 |
--------------------------------------------------------------------------------
/examples/sse/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 | vendor/
17 | .idea/
18 |
--------------------------------------------------------------------------------
/examples/embedfs/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 | vendor/
17 | .idea/
18 |
--------------------------------------------------------------------------------
/examples/pokedex/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 | vendor/
17 | .idea/
18 |
--------------------------------------------------------------------------------
/examples/render/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 | vendor/
17 | .idea/
18 |
--------------------------------------------------------------------------------
/examples/render/sidebar.html:
--------------------------------------------------------------------------------
1 | {{ $self := . }}
2 |
11 |
--------------------------------------------------------------------------------
/examples/sse/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Server Sent Time Example
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
--------------------------------------------------------------------------------
/examples/sse/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Getting Started
3 |
4 | * If not already installed, please install the [gonew](https://pkg.go.dev/golang.org/x/tools/cmd/gonew) command.
5 |
6 | ```console
7 | go install golang.org/x/tools/cmd/gonew@latest
8 | ```
9 |
10 | * Create a new project using this template.
11 | - Second argument passed to `gonew` is a module path of your new app.
12 |
13 | ```console
14 | gonew github.com/donseba/go-htmx/examples/sse your.module/my-app # e.g. github.com/donseba/my-app
15 | cd my-app
16 | go mod tidy
17 | go build
18 |
19 | ```
20 |
21 | ## Testing
22 |
23 | - Start your app
24 |
25 | ```console
26 | ./my-app
27 | ```
28 |
29 | - Open your browser http://localhost:3210/
30 |
--------------------------------------------------------------------------------
/examples/snake/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Getting Started
3 |
4 | * If not already installed, please install the [gonew](https://pkg.go.dev/golang.org/x/tools/cmd/gonew) command.
5 |
6 | ```console
7 | go install golang.org/x/tools/cmd/gonew@latest
8 | ```
9 |
10 | * Create a new project using this template.
11 | - Second argument passed to `gonew` is a module path of your new app.
12 |
13 | ```console
14 | gonew github.com/donseba/go-htmx/examples/snake your.module/my-app # e.g. github.com/donseba/my-app
15 | cd my-app
16 | go mod tidy
17 | go build
18 |
19 | ```
20 |
21 | ## Testing
22 |
23 | - Start your app
24 |
25 | ```console
26 | ./my-app
27 | ```
28 |
29 | - Open your browser http://localhost:3210/
30 |
--------------------------------------------------------------------------------
/examples/embedfs/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Getting Started
3 |
4 | * If not already installed, please install the [gonew](https://pkg.go.dev/golang.org/x/tools/cmd/gonew) command.
5 |
6 | ```console
7 | go install golang.org/x/tools/cmd/gonew@latest
8 | ```
9 |
10 | * Create a new project using this template.
11 | - Second argument passed to `gonew` is a module path of your new app.
12 |
13 | ```console
14 | gonew github.com/donseba/go-htmx/examples/render your.module/my-app # e.g. github.com/donseba/my-app
15 | cd my-app
16 | go mod tidy
17 | go build
18 |
19 | ```
20 |
21 | ## Testing
22 |
23 | - Start your app
24 |
25 | ```console
26 | ./my-app
27 | ```
28 |
29 | - Open your browser http://localhost:3210/
30 |
--------------------------------------------------------------------------------
/examples/render/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Getting Started
3 |
4 | * If not already installed, please install the [gonew](https://pkg.go.dev/golang.org/x/tools/cmd/gonew) command.
5 |
6 | ```console
7 | go install golang.org/x/tools/cmd/gonew@latest
8 | ```
9 |
10 | * Create a new project using this template.
11 | - Second argument passed to `gonew` is a module path of your new app.
12 |
13 | ```console
14 | gonew github.com/donseba/go-htmx/examples/render your.module/my-app # e.g. github.com/donseba/my-app
15 | cd my-app
16 | go mod tidy
17 | go build
18 |
19 | ```
20 |
21 | ## Testing
22 |
23 | - Start your app
24 |
25 | ```console
26 | ./my-app
27 | ```
28 |
29 | - Open your browser http://localhost:3210/
30 |
--------------------------------------------------------------------------------
/examples/tiktaktoe/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Getting Started
3 |
4 | * If not already installed, please install the [gonew](https://pkg.go.dev/golang.org/x/tools/cmd/gonew) command.
5 |
6 | ```console
7 | go install golang.org/x/tools/cmd/gonew@latest
8 | ```
9 |
10 | * Create a new project using this template.
11 | - Second argument passed to `gonew` is a module path of your new app.
12 |
13 | ```console
14 | gonew github.com/donseba/go-htmx/examples/tiktaktoe your.module/my-app # e.g. github.com/donseba/my-app
15 | cd my-app
16 | go mod tidy
17 | go build
18 |
19 | ```
20 |
21 | ## Testing
22 |
23 | - Start your app
24 |
25 | ```console
26 | ./my-app
27 | ```
28 |
29 | - Open your browser http://localhost:3210/
30 |
--------------------------------------------------------------------------------
/middleware/middleware.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "context"
5 | "net/http"
6 |
7 | "github.com/donseba/go-htmx"
8 | )
9 |
10 | // MiddleWare is a middleware that adds the htmx request header to the context
11 | // deprecated: htmx will retrieve the headers from the request by itself using htmx.NewHandler(w, r)
12 | func MiddleWare(next http.Handler) http.Handler {
13 | fn := func(w http.ResponseWriter, r *http.Request) {
14 | ctx := r.Context()
15 |
16 | hxh := htmx.HxRequestHeaderFromRequest(r)
17 |
18 | //nolint:staticcheck
19 | ctx = context.WithValue(ctx, htmx.ContextRequestHeader, hxh)
20 |
21 | next.ServeHTTP(w, r.WithContext(ctx))
22 | }
23 | return http.HandlerFunc(fn)
24 | }
25 |
--------------------------------------------------------------------------------
/examples/pokedex/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Getting Started
3 |
4 | * If not already installed, please install the [gonew](https://pkg.go.dev/golang.org/x/tools/cmd/gonew) command.
5 |
6 | ```console
7 | go install golang.org/x/tools/cmd/gonew@latest
8 | ```
9 |
10 | * Create a new project using this template.
11 | - Second argument passed to `gonew` is a module path of your new app.
12 |
13 | ```console
14 | gonew github.com/donseba/go-htmx/examples/pokedex your.module/my-app # e.g. github.com/donseba/my-app
15 | cd my-app
16 | go mod tidy
17 | go build
18 |
19 | ```
20 |
21 | ## Testing
22 |
23 | - Start your app
24 |
25 | ```console
26 | ./my-app
27 | ```
28 |
29 | - Open your browser http://localhost:3210/
30 | - Search `charizard` or `metapod`
31 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: donseba # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
16 |
--------------------------------------------------------------------------------
/examples/embedfs/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "embed"
5 | "fmt"
6 | "log"
7 | "net/http"
8 |
9 | "github.com/donseba/go-htmx"
10 | )
11 |
12 | //go:embed home.html
13 | var templates embed.FS
14 |
15 | type (
16 | App struct {
17 | htmx *htmx.HTMX
18 | }
19 |
20 | route struct {
21 | path string
22 | handler http.Handler
23 | }
24 | )
25 |
26 | func main() {
27 | // new app with htmx instance
28 | app := &App{
29 | htmx: htmx.New(),
30 | }
31 |
32 | mux := http.NewServeMux()
33 |
34 | htmx.UseTemplateCache = false
35 |
36 | mux.HandleFunc("/", app.Home)
37 |
38 | err := http.ListenAndServe(":3210", mux)
39 | log.Fatal(err)
40 | }
41 |
42 | func (a *App) Home(w http.ResponseWriter, r *http.Request) {
43 | h := a.htmx.NewHandler(w, r)
44 |
45 | data := map[string]any{
46 | "Text": "Welcome to the home page",
47 | }
48 |
49 | page := htmx.NewComponent("home.html").FS(templates).SetData(data)
50 |
51 | _, err := h.Render(r.Context(), page)
52 | if err != nil {
53 | fmt.Printf("error rendering page: %v", err.Error())
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/examples/tiktaktoe/board.gohtml:
--------------------------------------------------------------------------------
1 |
2 | {{ $hasWinner := false }}
3 | {{ with .Data.winner }}
4 | {{ $hasWinner = true }}
5 |
6 |
7 | {{if eq . "X"}}Player X wins!{{else if eq . "O"}}Player O wins!{{else}}It's a draw!{{end}}
8 |
9 |
10 | {{else}}
11 |
12 |
13 | {{.Data.game.Turn }} 's turn
14 |
15 |
16 | {{end}}
17 |
18 | {{range $i, $row := .Data.game.Board }}
19 |
20 | {{range $j, $cell := $row}}
21 |
22 |
23 | {{$cell}}
24 |
25 |
26 | {{end}}
27 |
28 | {{end}}
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Sebastiano Bellinzis
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.
--------------------------------------------------------------------------------
/examples/render/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ .Data.Title }}
7 |
8 |
9 |
10 |
11 |
20 |
21 |
22 |
23 |
partial and full page loading example
24 |
25 |
26 |
27 | {{ .Partials.Sidebar }}
28 |
29 |
30 | {{ .Partials.Content }}
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/examples/pokedex/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Pokedex Search
7 |
8 |
9 |
10 |
19 |
20 |
21 |
22 |
Pokedex Search
23 |
24 |
26 |
Loading...
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/examples/tiktaktoe/index.gohtml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Tic-Tac-Toe
7 |
8 |
47 |
48 |
49 | Tic-Tac-Toe
50 |
51 | {{ .Partials.board }}
52 |
53 | Reset Game
54 |
55 |
--------------------------------------------------------------------------------
/examples/pokedex/pokedex.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "io"
6 | "log"
7 | "net/http"
8 | "strings"
9 |
10 | "github.com/donseba/go-htmx"
11 | )
12 |
13 | type (
14 | App struct {
15 | htmx *htmx.HTMX
16 | }
17 |
18 | route struct {
19 | path string
20 | handler http.Handler
21 | }
22 |
23 | PokemonResponse struct {
24 | Name string `json:"name"`
25 | Sprites struct {
26 | FrontDefault string `json:"front_default"`
27 | } `json:"sprites"`
28 | }
29 | )
30 |
31 | func main() {
32 | // new app with htmx instance
33 | app := &App{
34 | htmx: htmx.New(),
35 | }
36 |
37 | mux := http.NewServeMux()
38 |
39 | mux.HandleFunc("GET /", app.Home)
40 | mux.HandleFunc("POST /search", app.Search)
41 |
42 | err := http.ListenAndServe(":3210", mux)
43 | log.Fatal(err)
44 | }
45 |
46 | func (a *App) Home(w http.ResponseWriter, r *http.Request) {
47 | http.ServeFile(w, r, "index.html")
48 | }
49 |
50 | func (a *App) Search(w http.ResponseWriter, r *http.Request) {
51 | query := strings.ToLower(r.PostFormValue("pokemon"))
52 | if query == "" {
53 | _, _ = w.Write([]byte("Please enter a Pokemon name."))
54 | return
55 | }
56 |
57 | resp, err := http.Get("https://pokeapi.co/api/v2/pokemon/" + query)
58 | if err != nil || resp.StatusCode != 200 {
59 | _, _ = w.Write([]byte("Pokemon not found."))
60 | return
61 | }
62 | defer resp.Body.Close()
63 |
64 | body, _ := io.ReadAll(resp.Body)
65 |
66 | var pokemon PokemonResponse
67 | _ = json.Unmarshal(body, &pokemon)
68 |
69 | result := ` `
70 | _, _ = w.Write([]byte(result))
71 | }
72 |
--------------------------------------------------------------------------------
/examples/snake/game.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "math/rand"
4 |
5 | func moveSnake(game *SnakeGame) {
6 | head := game.Snake[0]
7 | newHead := Position{X: head.X + game.Dir.X, Y: head.Y + game.Dir.Y}
8 |
9 | // Wrap around the edges of the board
10 | if newHead.X < 0 {
11 | newHead.X = 19 // Move to the rightmost edge
12 | } else if newHead.X >= 20 {
13 | newHead.X = 0 // Move to the leftmost edge
14 | }
15 |
16 | if newHead.Y < 0 {
17 | newHead.Y = 19 // Move to the bottom edge
18 | } else if newHead.Y >= 20 {
19 | newHead.Y = 0 // Move to the top edge
20 | }
21 |
22 | // Check if the snake eats the food
23 | if newHead == game.Food {
24 | // Grow the snake by not removing the last part
25 | placeFood(game) // Place new food
26 | } else {
27 | // Move the snake by removing the tail
28 | game.Snake = game.Snake[:len(game.Snake)-1]
29 | }
30 |
31 | // Add the new head to the front of the snake
32 | game.Snake = append([]Position{newHead}, game.Snake...)
33 |
34 | // Update the board
35 | for i := range game.Board {
36 | for j := range game.Board[i] {
37 | game.Board[i][j] = ""
38 | }
39 | }
40 | for _, pos := range game.Snake {
41 | game.Board[pos.X][pos.Y] = "S"
42 | }
43 | // Place food on the board
44 | game.Board[game.Food.X][game.Food.Y] = "F"
45 | }
46 |
47 | func placeFood(game *SnakeGame) {
48 | for {
49 | x := rand.Intn(20)
50 | y := rand.Intn(20)
51 | foodPos := Position{X: x, Y: y}
52 |
53 | // Ensure food is not placed on the snake
54 | occupied := false
55 | for _, pos := range game.Snake {
56 | if pos == foodPos {
57 | occupied = true
58 | break
59 | }
60 | }
61 | if !occupied {
62 | game.Food = foodPos
63 | game.Board[game.Food.X][game.Food.Y] = "F"
64 | break
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/examples/render/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 |
8 | "github.com/donseba/go-htmx"
9 | )
10 |
11 | type (
12 | App struct {
13 | htmx *htmx.HTMX
14 | }
15 |
16 | route struct {
17 | path string
18 | handler http.Handler
19 | }
20 | )
21 |
22 | func main() {
23 | // new app with htmx instance
24 | app := &App{
25 | htmx: htmx.New(),
26 | }
27 |
28 | mux := http.NewServeMux()
29 |
30 | htmx.UseTemplateCache = false
31 |
32 | mux.HandleFunc("/", app.Home)
33 | mux.HandleFunc("/child", app.Child)
34 |
35 | err := http.ListenAndServe(":3210", mux)
36 | log.Fatal(err)
37 | }
38 |
39 | func (a *App) Home(w http.ResponseWriter, r *http.Request) {
40 | h := a.htmx.NewHandler(w, r)
41 |
42 | data := map[string]any{
43 | "Text": "Welcome to the home page",
44 | }
45 |
46 | page := htmx.NewComponent("home.html").SetData(data).Wrap(mainContent(), "Content")
47 |
48 | _, err := h.Render(r.Context(), page)
49 | if err != nil {
50 | fmt.Printf("error rendering page: %v", err.Error())
51 | }
52 | }
53 |
54 | func (a *App) Child(w http.ResponseWriter, r *http.Request) {
55 | h := a.htmx.NewHandler(w, r)
56 |
57 | data := map[string]any{
58 | "Text": "Welcome to the child page",
59 | }
60 |
61 | page := htmx.NewComponent("child.html").SetData(data).Wrap(mainContent(), "Content")
62 |
63 | _, err := h.Render(r.Context(), page)
64 | if err != nil {
65 | fmt.Printf("error rendering page: %v", err.Error())
66 | }
67 | }
68 |
69 | func mainContent() htmx.RenderableComponent {
70 | menuItems := []struct {
71 | Name string
72 | Link string
73 | }{
74 | {"Home", "/"},
75 | {"Child", "/child"},
76 | }
77 |
78 | data := map[string]any{
79 | "Title": "Home",
80 | "MenuItems": menuItems,
81 | }
82 |
83 | sidebar := htmx.NewComponent("sidebar.html")
84 | return htmx.NewComponent("index.html").SetData(data).With(sidebar, "Sidebar")
85 | }
86 |
--------------------------------------------------------------------------------
/examples/sse/sse.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "html/template"
6 | "log"
7 | "math/rand"
8 | "net/http"
9 | "time"
10 |
11 | "github.com/donseba/go-htmx"
12 | "github.com/donseba/go-htmx/sse"
13 | )
14 |
15 | type (
16 | App struct {
17 | htmx *htmx.HTMX
18 | }
19 | )
20 |
21 | var (
22 | sseManager sse.Manager
23 | )
24 |
25 | func main() {
26 | app := &App{
27 | htmx: htmx.New(),
28 | }
29 |
30 | sseManager = sse.NewManager(5)
31 |
32 | go func() {
33 | for {
34 | time.Sleep(1 * time.Second) // Send a message every second
35 | sseManager.Send(sse.NewMessage(fmt.Sprintf("The current time is: %v
", time.Now().Format(time.RFC850))).WithEvent("time"))
36 | }
37 | }()
38 |
39 | go func() {
40 | for {
41 | clientsStr := ""
42 | clients := sseManager.Clients()
43 | for _, c := range clients {
44 | clientsStr += c + ", "
45 | }
46 |
47 | time.Sleep(1 * time.Second) // Send a message every seconds
48 | sseManager.Send(sse.NewMessage(fmt.Sprintf("connected clients: %v", clientsStr)).WithEvent("clients"))
49 | }
50 | }()
51 |
52 | mux := http.NewServeMux()
53 | mux.Handle("GET /", http.HandlerFunc(app.Home))
54 | mux.Handle("GET /sse", http.HandlerFunc(app.SSE))
55 |
56 | err := http.ListenAndServe(":3210", mux)
57 | log.Fatal(err)
58 | }
59 |
60 | func (a *App) Home(w http.ResponseWriter, r *http.Request) {
61 | tmpl, err := template.ParseFiles("index.html")
62 | if err != nil {
63 | http.Error(w, err.Error(), http.StatusInternalServerError)
64 | return
65 | }
66 |
67 | tmpl.Execute(w, nil)
68 | }
69 |
70 | func (a *App) SSE(w http.ResponseWriter, r *http.Request) {
71 | cl := sse.NewClient(randStringRunes(10))
72 |
73 | sseManager.Handle(w, r, cl)
74 | }
75 |
76 | var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
77 |
78 | func randStringRunes(n int) string {
79 | b := make([]rune, n)
80 | for i := range b {
81 | b[i] = letterRunes[rand.Intn(len(letterRunes))]
82 | }
83 | return string(b)
84 | }
85 |
--------------------------------------------------------------------------------
/examples/snake/index.gohtml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Snake Game with SSE
7 |
8 |
9 |
26 |
49 |
50 |
51 |
52 | Snake Game with SSE
53 |
54 | {{ .Partials.board }}
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/response.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "net/http"
5 | )
6 |
7 | type (
8 | HxResponseKey string
9 |
10 | HxResponseHeader struct {
11 | headers http.Header
12 | }
13 | )
14 |
15 | var (
16 | HXLocation HxResponseKey = "HX-Location" // Allows you to do a client-side redirect that does not do a full page reload
17 | HXPushUrl HxResponseKey = "HX-Push-Url" // pushes a new url into the history stack
18 | HXRedirect HxResponseKey = "HX-Redirect" // can be used to do a client-side redirect to a new location
19 | HXRefresh HxResponseKey = "HX-Refresh" // if set to "true" the client side will do a full refresh of the page
20 | HXReplaceUrl HxResponseKey = "HX-Replace-Url" // replaces the current URL in the location bar
21 | HXReswap HxResponseKey = "HX-Reswap" // Allows you to specify how the response will be swapped. See hx-swap for possible values
22 | HXRetarget HxResponseKey = "HX-Retarget" // A CSS selector that updates the target of the content update to a different element on the page
23 | HXReselect HxResponseKey = "HX-Reselect" // A CSS selector that allows you to choose which part of the response is used to be swapped in. Overrides an existing hx-select on the triggering element
24 | HXTrigger HxResponseKey = "HX-Trigger" // allows you to trigger client side events, see the documentation for more info
25 | HXTriggerAfterSettle HxResponseKey = "HX-Trigger-After-Settle" // allows you to trigger client side events, see the documentation for more info
26 | HXTriggerAfterSwap HxResponseKey = "HX-Trigger-After-Swap" // allows you to trigger client side events, see the documentation for more info
27 | )
28 |
29 | func (h *HTMX) HxResponseHeader(headers http.Header) *HxResponseHeader {
30 | return &HxResponseHeader{
31 | headers: headers,
32 | }
33 | }
34 |
35 | func (h HxResponseKey) String() string {
36 | return string(h)
37 | }
38 |
39 | func (h *HxResponseHeader) Set(k HxResponseKey, val string) {
40 | h.headers.Set(k.String(), val)
41 | }
42 |
43 | func (h *HxResponseHeader) Get(k HxResponseKey) string {
44 | return h.headers.Get(k.String())
45 | }
46 |
--------------------------------------------------------------------------------
/.github/workflows/golangci-lint.yml:
--------------------------------------------------------------------------------
1 | name: golangci-lint
2 | on:
3 | push:
4 | branches:
5 | - master
6 | - main
7 | pull_request:
8 |
9 | permissions:
10 | contents: read
11 | # Optional: allow read access to pull request. Use with `only-new-issues` option.
12 | # pull-requests: read
13 |
14 | jobs:
15 | golangci:
16 | name: lint
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: actions/checkout@v3
20 | - uses: actions/setup-go@v4
21 | with:
22 | go-version: '1.22'
23 | cache: false
24 | - name: golangci-lint
25 | uses: golangci/golangci-lint-action@v3
26 | with:
27 | # Require: The version of golangci-lint to use.
28 | # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version.
29 | # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit.
30 | version: latest
31 |
32 | # Optional: working directory, useful for monorepos
33 | # working-directory: somedir
34 |
35 | # Optional: golangci-lint command line arguments.
36 | #
37 | # Note: By default, the `.golangci.yml` file should be at the root of the repository.
38 | # The location of the configuration file can be changed by using `--config=`
39 | # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0
40 | args: --out-format=colored-line-number
41 |
42 | # Optional: show only new issues if it's a pull request. The default value is `false`.
43 | # only-new-issues: true
44 |
45 | # Optional: if set to true, then all caching functionality will be completely disabled,
46 | # takes precedence over all other caching options.
47 | # skip-cache: true
48 |
49 | # Optional: if set to true, then the action won't cache or restore ~/go/pkg.
50 | # skip-pkg-cache: true
51 |
52 | # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build.
53 | # skip-build-cache: true
54 |
55 | # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'.
56 | # install-mode: "goinstall"
--------------------------------------------------------------------------------
/request.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "net/http"
5 | )
6 |
7 | const (
8 | // ContextRequestHeader is the context key for the htmx request header.
9 | ContextRequestHeader = "htmx-request-header"
10 |
11 | HxRequestHeaderBoosted HxRequestHeaderKey = "HX-Boosted"
12 | HxRequestHeaderCurrentURL HxRequestHeaderKey = "HX-Current-URL"
13 | HxRequestHeaderHistoryRestoreRequest HxRequestHeaderKey = "HX-History-Restore-Request"
14 | HxRequestHeaderPrompt HxRequestHeaderKey = "HX-Prompt"
15 | HxRequestHeaderRequest HxRequestHeaderKey = "HX-Request"
16 | HxRequestHeaderTarget HxRequestHeaderKey = "HX-Target"
17 | HxRequestHeaderTriggerName HxRequestHeaderKey = "HX-Trigger-Name"
18 | HxRequestHeaderTrigger HxRequestHeaderKey = "HX-Trigger"
19 | )
20 |
21 | type (
22 | HxRequestHeaderKey string
23 |
24 | HxRequestHeader struct {
25 | HxBoosted bool
26 | HxCurrentURL string
27 | HxHistoryRestoreRequest bool
28 | HxPrompt string
29 | HxRequest bool
30 | HxTarget string
31 | HxTriggerName string
32 | HxTrigger string
33 | }
34 | )
35 |
36 | func HxRequestHeaderFromRequest(r *http.Request) HxRequestHeader {
37 | return HxRequestHeader{
38 | HxBoosted: HxStrToBool(r.Header.Get(HxRequestHeaderBoosted.String())),
39 | HxCurrentURL: r.Header.Get(HxRequestHeaderCurrentURL.String()),
40 | HxHistoryRestoreRequest: HxStrToBool(r.Header.Get(HxRequestHeaderHistoryRestoreRequest.String())),
41 | HxPrompt: r.Header.Get(HxRequestHeaderPrompt.String()),
42 | HxRequest: HxStrToBool(r.Header.Get(HxRequestHeaderRequest.String())),
43 | HxTarget: r.Header.Get(HxRequestHeaderTarget.String()),
44 | HxTriggerName: r.Header.Get(HxRequestHeaderTriggerName.String()),
45 | HxTrigger: r.Header.Get(HxRequestHeaderTrigger.String()),
46 | }
47 | }
48 |
49 | func (h *HTMX) HxHeader(r *http.Request) HxRequestHeader {
50 | header := r.Context().Value(ContextRequestHeader)
51 |
52 | if val, ok := header.(HxRequestHeader); ok {
53 | return val
54 | }
55 |
56 | // if the header is not found from the middleware, try and populate it from the request
57 | return HxRequestHeaderFromRequest(r)
58 | }
59 |
60 | func (x HxRequestHeaderKey) String() string {
61 | return string(x)
62 | }
63 |
--------------------------------------------------------------------------------
/examples/tiktaktoe/tiktaktoe.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/donseba/go-htmx"
5 | "log"
6 | "net/http"
7 | "strconv"
8 | "sync"
9 | )
10 |
11 | type (
12 | App struct {
13 | htmx *htmx.HTMX
14 | }
15 |
16 | Game struct {
17 | Board [3][3]string
18 | Turn string
19 | Mu sync.Mutex
20 | }
21 | )
22 |
23 | var game = Game{
24 | Board: [3][3]string{},
25 | Turn: "X",
26 | }
27 |
28 | func main() {
29 | // new app with htmx instance
30 | app := &App{
31 | htmx: htmx.New(),
32 | }
33 |
34 | mux := http.NewServeMux()
35 |
36 | htmx.UseTemplateCache = false
37 |
38 | mux.Handle("GET /", http.HandlerFunc(app.Home))
39 | mux.Handle("PUT /set/{row}/{column}", http.HandlerFunc(app.Set))
40 | mux.Handle("GET /reset", http.HandlerFunc(app.Reset))
41 |
42 | err := http.ListenAndServe(":3210", mux)
43 | log.Fatal(err)
44 | }
45 |
46 | func (a *App) Home(w http.ResponseWriter, r *http.Request) {
47 | game.Mu.Lock()
48 | defer game.Mu.Unlock()
49 |
50 | h := a.htmx.NewHandler(w, r)
51 |
52 | page := htmx.NewComponent("board.gohtml").SetData(map[string]any{
53 | "game": &game,
54 | "winner": checkWinner(game.Board),
55 | }).Wrap(mainContent(), "board")
56 |
57 | _, err := h.Render(r.Context(), page)
58 | if err != nil {
59 | log.Printf("error rendering page: %v", err.Error())
60 | }
61 | }
62 |
63 | func (a *App) Set(w http.ResponseWriter, r *http.Request) {
64 | game.Mu.Lock()
65 | defer game.Mu.Unlock()
66 |
67 | h := a.htmx.NewHandler(w, r)
68 |
69 | row, _ := strconv.Atoi(r.PathValue("row"))
70 | column, _ := strconv.Atoi(r.PathValue("column"))
71 |
72 | game.Board[row][column] = game.Turn
73 | if game.Turn == "X" {
74 | game.Turn = "O"
75 | } else {
76 | game.Turn = "X"
77 | }
78 |
79 | page := htmx.NewComponent("board.gohtml").SetData(map[string]any{
80 | "game": &game,
81 | "winner": checkWinner(game.Board),
82 | }).Wrap(mainContent(), "board")
83 |
84 | _, err := h.Render(r.Context(), page)
85 | if err != nil {
86 | log.Printf("error rendering page: %v", err.Error())
87 | }
88 | }
89 |
90 | func (a *App) Reset(w http.ResponseWriter, r *http.Request) {
91 | game.Mu.Lock()
92 | defer game.Mu.Unlock()
93 |
94 | h := a.htmx.NewHandler(w, r)
95 |
96 | game.Board = [3][3]string{}
97 | game.Turn = "X"
98 |
99 | page := htmx.NewComponent("board.gohtml").SetData(map[string]any{
100 | "game": &game,
101 | }).Wrap(mainContent(), "board")
102 |
103 | _, err := h.Render(r.Context(), page)
104 | if err != nil {
105 | log.Printf("error rendering page: %v", err.Error())
106 | }
107 | }
108 |
109 | func checkWinner(board [3][3]string) string {
110 | // Check rows, columns, and diagonals for a winner
111 | for i := 0; i < 3; i++ {
112 | if board[i][0] == board[i][1] && board[i][1] == board[i][2] && board[i][0] != "" {
113 | return board[i][0]
114 | }
115 | if board[0][i] == board[1][i] && board[1][i] == board[2][i] && board[0][i] != "" {
116 | return board[0][i]
117 | }
118 | }
119 |
120 | if board[0][0] == board[1][1] && board[1][1] == board[2][2] && board[0][0] != "" {
121 | return board[0][0]
122 | }
123 |
124 | if board[0][2] == board[1][1] && board[1][1] == board[2][0] && board[0][2] != "" {
125 | return board[0][2]
126 | }
127 |
128 | return ""
129 | }
130 |
131 | func mainContent() htmx.RenderableComponent {
132 | return htmx.NewComponent("index.gohtml")
133 | }
134 |
--------------------------------------------------------------------------------
/examples/snake/snake.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "github.com/donseba/go-htmx"
6 | "github.com/donseba/go-htmx/sse"
7 | "log"
8 | "math/rand"
9 | "net/http"
10 | "regexp"
11 | "sync"
12 | "time"
13 | )
14 |
15 | type (
16 | App struct {
17 | htmx *htmx.HTMX
18 | game SnakeGame
19 | }
20 |
21 | Position struct {
22 | X, Y int
23 | }
24 |
25 | SnakeGame struct {
26 | Board [20][20]string
27 | Snake []Position
28 | Food Position
29 | Dir Position
30 | Mu sync.Mutex
31 | Active bool
32 | }
33 | )
34 |
35 | var (
36 | sseManager sse.Manager
37 | )
38 |
39 | func main() {
40 |
41 | app := &App{
42 | htmx: htmx.New(),
43 | game: SnakeGame{
44 | Dir: Position{X: 1, Y: 0},
45 | Snake: []Position{{X: 10, Y: 10}, {X: 9, Y: 10}, {X: 8, Y: 10}},
46 | Active: true,
47 | },
48 | }
49 |
50 | placeFood(&app.game)
51 |
52 | sseManager = sse.NewManager(5)
53 |
54 | go func() {
55 | for {
56 | app.game.Mu.Lock()
57 | if app.game.Active {
58 | moveSnake(&app.game)
59 | }
60 | app.game.Mu.Unlock()
61 |
62 | page := htmx.NewComponent("board.gohtml").SetData(map[string]any{
63 | "game": &app.game,
64 | })
65 |
66 | out, err := page.Render(context.Background())
67 | if err != nil {
68 | log.Printf("error rendering page: %v", err.Error())
69 | }
70 |
71 | re := regexp.MustCompile(`\s+`)
72 | stringOut := re.ReplaceAllString(string(out), "")
73 |
74 | sseManager.Send(sse.NewMessage(stringOut).WithEvent("board"))
75 | time.Sleep(150 * time.Millisecond)
76 | }
77 | }()
78 |
79 | mux := http.NewServeMux()
80 | mux.Handle("GET /", http.HandlerFunc(app.Home))
81 | mux.Handle("GET /sse", http.HandlerFunc(app.SSE))
82 | mux.Handle("PUT /move/{dir}", http.HandlerFunc(app.Move))
83 | mux.Handle("PUT /pause", http.HandlerFunc(app.Pause))
84 |
85 | err := http.ListenAndServe(":3210", mux)
86 | log.Fatal(err)
87 | }
88 |
89 | func (a *App) Home(w http.ResponseWriter, r *http.Request) {
90 | a.game.Mu.Lock()
91 | defer a.game.Mu.Unlock()
92 |
93 | h := a.htmx.NewHandler(w, r)
94 |
95 | page := htmx.NewComponent("board.gohtml").SetData(map[string]any{
96 | "game": &a.game,
97 | }).Wrap(mainContent(), "board")
98 |
99 | _, err := h.Render(r.Context(), page)
100 | if err != nil {
101 | log.Printf("error rendering page: %v", err.Error())
102 | }
103 | }
104 |
105 | func (a *App) Move(w http.ResponseWriter, r *http.Request) {
106 | a.game.Mu.Lock()
107 | defer a.game.Mu.Unlock()
108 |
109 | dir := r.PathValue("dir")
110 | switch dir {
111 | case "up":
112 | a.game.Dir = Position{X: -1, Y: 0}
113 | case "down":
114 | a.game.Dir = Position{X: 1, Y: 0}
115 | case "left":
116 | a.game.Dir = Position{X: 0, Y: -1}
117 | case "right":
118 | a.game.Dir = Position{X: 0, Y: 1}
119 | }
120 |
121 | w.WriteHeader(http.StatusNoContent)
122 | _, _ = w.Write(nil)
123 | }
124 |
125 | func (a *App) Pause(w http.ResponseWriter, r *http.Request) {
126 | a.game.Mu.Lock()
127 | defer a.game.Mu.Unlock()
128 |
129 | a.game.Active = !a.game.Active
130 |
131 | w.WriteHeader(http.StatusNoContent)
132 | _, _ = w.Write(nil)
133 | }
134 |
135 | func (a *App) SSE(w http.ResponseWriter, r *http.Request) {
136 | cl := sse.NewClient(randStringRunes(10))
137 |
138 | sseManager.Handle(w, r, cl)
139 | }
140 |
141 | var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
142 |
143 | func randStringRunes(n int) string {
144 | b := make([]rune, n)
145 | for i := range b {
146 | b[i] = letterRunes[rand.Intn(len(letterRunes))]
147 | }
148 | return string(b)
149 | }
150 |
151 | func mainContent() htmx.RenderableComponent {
152 | return htmx.NewComponent("index.gohtml")
153 | }
154 |
--------------------------------------------------------------------------------
/trigger_test.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "net/http"
5 | "testing"
6 | )
7 |
8 | func TestNewTriggerMixed(t *testing.T) {
9 | trigger := NewTrigger()
10 |
11 | if trigger == nil {
12 | t.Error("expected trigger to not be nil")
13 | }
14 |
15 | trigger.AddEvent("foo").
16 | AddEventDetailed("bar", "baz").
17 | AddEventDetailed("qux", "quux").
18 | AddEventObject("corge", map[string]any{"grault": "garply", "waldo": "fred", "plugh": "xyzzy", "thud": "wibble"})
19 |
20 | expected := `{"bar":"baz","corge":{"grault":"garply","plugh":"xyzzy","thud":"wibble","waldo":"fred"},"foo":"","qux":"quux"}`
21 |
22 | if trigger.String() != expected {
23 | t.Errorf("expected trigger to be %v, got %v", expected, trigger.String())
24 | }
25 | }
26 |
27 | func TestNewTriggerSingle(t *testing.T) {
28 | trigger := NewTrigger()
29 |
30 | if trigger == nil {
31 | t.Error("expected trigger to not be nil")
32 | }
33 |
34 | trigger.AddEvent("foo").
35 | AddEvent("bar").
36 | AddEvent("baz")
37 |
38 | expected := "foo, bar, baz"
39 |
40 | if trigger.String() != expected {
41 | t.Errorf("expected trigger to be %v, got %v", expected, trigger.String())
42 | }
43 | }
44 |
45 | func TestNewTriggerMixedNested(t *testing.T) {
46 | trigger := NewTrigger()
47 |
48 | if trigger == nil {
49 | t.Error("expected trigger to not be nil")
50 | }
51 |
52 | trigger.AddEvent("foo").
53 | AddEventDetailed("bar", "baz").
54 | AddEventDetailed("qux", "quux").
55 | AddEventObject("corge", map[string]any{"grault": "garply", "waldo": "fred", "plugh": "xyzzy", "thud": map[string]any{"foo": "bar", "baz": "qux"}}).AddSuccess("successfully tested", map[string]any{"foo": "bar", "baz": "qux"})
56 | expected := `{"bar":"baz","corge":{"grault":"garply","plugh":"xyzzy","thud":{"baz":"qux","foo":"bar"},"waldo":"fred"},"foo":"","qux":"quux","showMessage":{"baz":"qux","foo":"bar","level":"success","message":"successfully tested"}}`
57 |
58 | if trigger.String() != expected {
59 | t.Errorf("expected trigger to be %v, got %v", expected, trigger.String())
60 | }
61 | }
62 |
63 | func TestTriggerSuccess(t *testing.T) {
64 | req := &http.Request{}
65 | handler := New().NewHandler(dummyWriter{}, req)
66 | handler.TriggerSuccess("successfully tested", map[string]any{"foo": "bar", "baz": "qux"})
67 |
68 | expected := `{"showMessage":{"baz":"qux","foo":"bar","level":"success","message":"successfully tested"}}`
69 |
70 | equal(t, expected, handler.response.Get(HXTrigger))
71 | }
72 |
73 | func TestTriggerError(t *testing.T) {
74 | req := &http.Request{}
75 | handler := New().NewHandler(dummyWriter{}, req)
76 | handler.TriggerError("successfully tested a fail", map[string]any{"foo": "bar", "baz": "qux"})
77 |
78 | expected := `{"showMessage":{"baz":"qux","foo":"bar","level":"error","message":"successfully tested a fail"}}`
79 |
80 | equal(t, expected, handler.response.Get(HXTrigger))
81 | }
82 |
83 | func TestTriggerInfo(t *testing.T) {
84 | req := &http.Request{}
85 | handler := New().NewHandler(dummyWriter{}, req)
86 | handler.TriggerInfo("successfully tested some info", map[string]any{"foo": "bar", "baz": "qux"})
87 |
88 | expected := `{"showMessage":{"baz":"qux","foo":"bar","level":"info","message":"successfully tested some info"}}`
89 |
90 | equal(t, expected, handler.response.Get(HXTrigger))
91 | }
92 |
93 | func TestTriggerWarning(t *testing.T) {
94 | req := &http.Request{}
95 | handler := New().NewHandler(dummyWriter{}, req)
96 | handler.TriggerWarning("successfully tested a warning", map[string]any{"foo": "bar", "baz": "qux"})
97 |
98 | expected := `{"showMessage":{"baz":"qux","foo":"bar","level":"warning","message":"successfully tested a warning"}}`
99 |
100 | equal(t, expected, handler.response.Get(HXTrigger))
101 | }
102 |
--------------------------------------------------------------------------------
/htmx.go:
--------------------------------------------------------------------------------
1 | // Package htmx offers a streamlined integration with HTMX in Go applications.
2 | // It implements the standard io.Writer interface and includes middleware support, but it is not required.
3 | // Allowing for the effortless incorporation of HTMX features into existing Go applications.
4 | package htmx
5 |
6 | import (
7 | "errors"
8 | "github.com/donseba/go-htmx/sse"
9 | "log/slog"
10 | "net/http"
11 | "strings"
12 | "time"
13 | )
14 |
15 | var (
16 | DefaultSwapDuration = time.Duration(0 * time.Millisecond)
17 | DefaultSettleDelay = time.Duration(20 * time.Millisecond)
18 |
19 | DefaultNotificationKey = "showMessage"
20 | DefaultSSEWorkerPoolSize = 5
21 | )
22 |
23 | // this is the default sseManager implementation which is created to handle the server-sent events.
24 | var sseManager sse.Manager
25 |
26 | type (
27 | Logger interface {
28 | Warn(msg string, args ...any)
29 | }
30 |
31 | HTMX struct {
32 | log Logger
33 | }
34 | )
35 |
36 | // New returns a new htmx instance.
37 | func New() *HTMX {
38 | return &HTMX{
39 | log: slog.Default().WithGroup("htmx"),
40 | }
41 | }
42 |
43 | // SetLog sets the logger for the htmx instance.
44 | func (h *HTMX) SetLog(log Logger) {
45 | h.log = log
46 | }
47 |
48 | // NewHandler returns a new htmx handler.
49 | func (h *HTMX) NewHandler(w http.ResponseWriter, r *http.Request) *Handler {
50 | return &Handler{
51 | w: w,
52 | r: r,
53 | request: h.HxHeader(r),
54 | response: h.HxResponseHeader(w.Header()),
55 | log: h.log,
56 | }
57 | }
58 |
59 | // NewSSE creates a new sse manager with the specified worker pool size.
60 | func (h *HTMX) NewSSE(workerPoolSize int) error {
61 | if sseManager != nil {
62 | return errors.New("sse manager already exists")
63 | }
64 |
65 | sseManager = sse.NewManager(workerPoolSize)
66 | return nil
67 | }
68 |
69 | // SSEHandler handles the server-sent events. this is a shortcut and is not the preferred way to handle sse.
70 | func (h *HTMX) SSEHandler(w http.ResponseWriter, r *http.Request, cl sse.Listener) {
71 | if sseManager == nil {
72 | sseManager = sse.NewManager(DefaultSSEWorkerPoolSize)
73 | }
74 |
75 | sseManager.Handle(w, r, cl)
76 | }
77 |
78 | // SSESend sends a message to all connected clients.
79 | func (h *HTMX) SSESend(message sse.Envelope) {
80 | if sseManager == nil {
81 | sseManager = sse.NewManager(DefaultSSEWorkerPoolSize)
82 | }
83 |
84 | sseManager.Send(message)
85 | }
86 |
87 | // IsHxRequest returns true if the request is a htmx request.
88 | func IsHxRequest(r *http.Request) bool {
89 | return HxStrToBool(r.Header.Get(HxRequestHeaderRequest.String()))
90 | }
91 |
92 | // IsHxBoosted returns true if the request is a htmx request and the request is boosted
93 | func IsHxBoosted(r *http.Request) bool {
94 | return HxStrToBool(r.Header.Get(HxRequestHeaderBoosted.String()))
95 | }
96 |
97 | // IsHxHistoryRestoreRequest returns true if the request is a htmx request and the request is a history restore request
98 | func IsHxHistoryRestoreRequest(r *http.Request) bool {
99 | return HxStrToBool(r.Header.Get(HxRequestHeaderHistoryRestoreRequest.String()))
100 | }
101 |
102 | // RenderPartial returns true if the request is an HTMX request that is either boosted or a hx request,
103 | // provided it is not a history restore request.
104 | func RenderPartial(r *http.Request) bool {
105 | return (IsHxRequest(r) || IsHxBoosted(r)) && !IsHxHistoryRestoreRequest(r)
106 | }
107 |
108 | // HxStrToBool converts a string to a boolean value.
109 | func HxStrToBool(str string) bool {
110 | return strings.EqualFold(str, "true")
111 | }
112 |
113 | // HxBoolToStr converts a boolean value to a string.
114 | func HxBoolToStr(b bool) string {
115 | if b {
116 | return "true"
117 | }
118 |
119 | return "false"
120 | }
121 |
--------------------------------------------------------------------------------
/trigger.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 | )
7 |
8 | type eventContent struct {
9 | event string
10 | data any
11 | }
12 |
13 | type Trigger struct {
14 | triggers []eventContent
15 | onlySimple bool
16 | }
17 |
18 | // NewTrigger returns a new Trigger set
19 | func NewTrigger() *Trigger {
20 | return &Trigger{
21 | triggers: make([]eventContent, 0),
22 | onlySimple: true,
23 | }
24 | }
25 |
26 | // add adds a trigger to the Trigger set
27 | func (t *Trigger) add(trigger eventContent) *Trigger {
28 | t.triggers = append(t.triggers, trigger)
29 |
30 | return t
31 | }
32 |
33 | func (t *Trigger) AddEvent(event string) *Trigger {
34 | return t.add(eventContent{event: event, data: ""})
35 | }
36 |
37 | // AddEventDetailed adds a trigger to the Trigger set
38 | func (t *Trigger) AddEventDetailed(event, message string) *Trigger {
39 | t.onlySimple = false
40 |
41 | return t.add(eventContent{event: event, data: message})
42 | }
43 |
44 | // AddEventObject adds a trigger to the Trigger set
45 | func (t *Trigger) AddEventObject(event string, details map[string]any) *Trigger {
46 | t.onlySimple = false
47 |
48 | return t.add(eventContent{event: event, data: details})
49 | }
50 |
51 | func (t *Trigger) AddSuccess(message string, vars ...map[string]any) {
52 | t.addNotifyObject(notificationSuccess, message, vars...)
53 | }
54 |
55 | func (t *Trigger) AddInfo(message string, vars ...map[string]any) {
56 | t.addNotifyObject(notificationInfo, message, vars...)
57 | }
58 |
59 | func (t *Trigger) AddWarning(message string, vars ...map[string]any) {
60 | t.addNotifyObject(notificationWarning, message, vars...)
61 | }
62 |
63 | func (t *Trigger) AddError(message string, vars ...map[string]any) {
64 | t.addNotifyObject(notificationError, message, vars...)
65 | }
66 |
67 | func (t *Trigger) addNotifyObject(nt notificationType, message string, vars ...map[string]any) *Trigger {
68 | details := map[string]any{
69 | notificationKeyLevel: nt,
70 | notificationKeyMessage: message,
71 | }
72 |
73 | if len(vars) > 0 {
74 | for _, m := range vars {
75 | for k, v := range m {
76 | if k == notificationKeyLevel || k == notificationKeyMessage {
77 | k = "_" + k
78 | }
79 | details[k] = v
80 | }
81 | }
82 | }
83 |
84 | return t.AddEventObject(DefaultNotificationKey, details)
85 | }
86 |
87 | // String returns the string representation of the Trigger set
88 | func (t *Trigger) String() string {
89 | if t.onlySimple {
90 | data := make([]string, len(t.triggers))
91 |
92 | for i, trigger := range t.triggers {
93 | data[i] = trigger.event
94 | }
95 |
96 | return strings.Join(data, ", ")
97 | }
98 |
99 | triggerMap := make(map[string]any)
100 | for _, tr := range t.triggers {
101 | triggerMap[tr.event] = tr.data
102 | }
103 | data, _ := json.Marshal(triggerMap)
104 | return string(data)
105 | }
106 |
107 | const (
108 | // notificationSuccess is the success notification type
109 | notificationSuccess notificationType = "success"
110 | // notificationInfo is the info notification type
111 | notificationInfo notificationType = "info"
112 | // notificationWarning is the warning notification type
113 | notificationWarning notificationType = "warning"
114 | // notificationError is the error notification type
115 | notificationError notificationType = "error"
116 | // notificationKeyLevel is the notification level key
117 | notificationKeyLevel = "level"
118 | // notificationKeyMessage is the notification message key
119 | notificationKeyMessage = "message"
120 | )
121 |
122 | type notificationType string
123 |
124 | func (n *notificationType) String() string {
125 | return string(*n)
126 | }
127 |
128 | func (h *Handler) notifyObject(nt notificationType, message string, vars ...map[string]any) {
129 | t := NewTrigger().addNotifyObject(nt, message, vars...)
130 | h.TriggerWithObject(t)
131 | }
132 |
133 | func (h *Handler) TriggerSuccess(message string, vars ...map[string]any) {
134 | h.notifyObject(notificationSuccess, message, vars...)
135 | }
136 |
137 | func (h *Handler) TriggerInfo(message string, vars ...map[string]any) {
138 | h.notifyObject(notificationInfo, message, vars...)
139 | }
140 |
141 | func (h *Handler) TriggerWarning(message string, vars ...map[string]any) {
142 | h.notifyObject(notificationWarning, message, vars...)
143 | }
144 |
145 | func (h *Handler) TriggerError(message string, vars ...map[string]any) {
146 | h.notifyObject(notificationError, message, vars...)
147 | }
148 |
149 | func (h *Handler) TriggerCustom(custom, message string, vars ...map[string]any) {
150 | h.notifyObject(notificationType(custom), message, vars...)
151 | }
152 |
--------------------------------------------------------------------------------
/DESIGN_DECISIONS.md:
--------------------------------------------------------------------------------
1 | # Design Decisions for `go-htmx`: Choosing Standard Templates over `gomponents` and `templ`
2 |
3 | When developing the go-htmx package, it's essential to choose an approach that aligns with the project's goals, leverages Go's strengths, and meets the needs of its users. Here's why we prefer not to adopt the direction of frameworks like gomponents or gotempl:
4 | 1. Simplicity and Familiarity
5 | - **Standard Library Usage**: `go-htmx` utilizes Go's built-in html/template package, which is familiar to most Go developers. This reduces the learning curve and leverages well-established practices.
6 | - **Avoiding New Abstractions**: Introducing new templating languages or paradigms (as in `gomponents` or `templ`) adds complexity. By sticking with standard templates, we keep things simple and straightforward.
7 |
8 | 2. Separation of Concerns
9 |
10 | - **Clear Division**: Using templates allows for a clear separation between business logic (Go code) and presentation logic (HTML templates). This promotes cleaner, more maintainable code.
11 | - **Design Collaboration**: Designers can work on HTML templates without needing to understand Go code, facilitating better collaboration between developers and designers.
12 |
13 | 3. Readability and Maintainability
14 |
15 | - **Cleaner Codebase**: Mixing HTML generation with Go code can lead to verbose and less readable code. Keeping templates in separate files improves readability.
16 | - **Ease of Maintenance**: Separate templates are easier to update and maintain, especially in large projects with multiple contributors.
17 |
18 | 4. Performance Considerations
19 |
20 | - **Efficient Rendering**: Go's template engine is optimized for performance. By using cached templates and avoiding runtime code generation, we achieve efficient rendering.
21 | - **Reduced Overhead**: Avoiding additional abstraction layers minimizes overhead and potential performance bottlenecks.
22 |
23 | 5. Flexibility and Extensibility
24 |
25 | - **Template Extensibility**: Standard templates can be extended and customized using template.FuncMap, allowing for powerful template functions without locking into a specific framework's way of doing things.
26 | - **Partial Rendering Support**: go-htmx supports partial rendering and template wrapping out of the box, providing flexibility in how components are composed.
27 |
28 | 6. Compatibility and Integration
29 |
30 | - **Ecosystem Compatibility**: By adhering to Go's standard library, go-htmx is compatible with a wide range of existing libraries and tools.
31 | - **Ease of Integration**: Projects that already use html/template can integrate go-htmx without significant refactoring or adaptation.
32 |
33 | 7. Avoiding Vendor Lock-in
34 |
35 | - **Long-Term Stability**: Relying on the standard library reduces dependency on third-party packages that may become unmaintained or introduce breaking changes.
36 | - **Open Standards**: Using widely adopted standards ensures better support and a larger community for troubleshooting and enhancements.
37 |
38 | 8. Designer-Friendly Approach
39 |
40 | - **Template Editing Tools**: Designers can use standard HTML editors and tools with syntax highlighting, validation, and auto-completion to work on templates.
41 | - **No Need for Go Expertise**: Keeping templates in HTML allows designers to contribute without needing to learn Go-specific component syntax or code structures.
42 |
43 | 9. Learning from Other Ecosystems
44 |
45 | - **Avoiding Complexity**: Other languages and frameworks have moved away from embedding logic directly into templates or views due to complexity and maintenance challenges.
46 | - **Established Best Practices**: The Go community often emphasizes simplicity and clarity. By following established best practices, we align with the community's values.
47 |
48 | 10. Focused Scope and Purpose
49 |
50 | - **Specific Use Cases**: go-htmx is designed to enhance the rendering of templates in the context of htmx interactions. Adopting a different templating paradigm might dilute its focus.
51 | - **Maintainability**: Keeping the package focused on its core functionality makes it easier to maintain and evolve over time.
52 |
53 | ---
54 |
55 | ## Conclusion
56 |
57 | While frameworks like `gomponents` and `templ` offer alternative approaches to building web applications in Go, they introduce paradigms that may not align with the goals of the go-htmx package. By leveraging Go's standard templating system and focusing on simplicity, maintainability, and compatibility, go-htmx provides a solution that is both powerful and accessible.
58 |
59 | ## Summary of Reasons:
60 |
61 | - **Simplicity**: Avoiding unnecessary complexity by using standard templates.
62 | - **Maintainability**: Easier to read, update, and maintain codebases.
63 | - **Compatibility**: Seamless integration with existing Go tools and libraries.
64 | - **Performance**: Efficient rendering without additional overhead.
65 | - **Community Alignment**: Following Go community best practices and conventions.
66 |
67 | Note: It's important to choose the right tool for the job. While alternative frameworks may be suitable for certain projects, the decision to stick with standard templates in go-htmx is based on the desire to keep the package simple, maintainable, and broadly compatible.
--------------------------------------------------------------------------------
/LOB.md:
--------------------------------------------------------------------------------
1 | # Does Using Standard Templates in go-htmx Align with the Locality of Behavior (LoB) Principle in HTMX?
2 |
3 | ---
4 |
5 | #### Short Answer:
6 | **Yes**, the decision to use standard templates in go-htmx aligns with the Locality of Behavior (LoB) principle that HTMX advocates. This is because it keeps the behavior (template rendering logic) close to the HTML structure, promoting clarity and maintainability.
7 |
8 | ---
9 |
10 | ## Understanding the Locality of Behavior (LoB) Principle
11 |
12 | Locality of Behavior (LoB) is a design principle emphasized by HTMX, which states:
13 |
14 | "Code that affects an element should be directly on that element."
15 |
16 | This principle promotes the idea that the behavior and logic associated with a particular piece of UI should be located as close as possible to the HTML that represents it. This enhances:
17 |
18 | - **Readability**: Developers can easily understand what an element does by looking at its markup.
19 | - **Maintainability**: Changes to an element's behavior can be made in one place.
20 | - **Encapsulation**: Elements are self-contained, reducing side effects and unintended interactions.
21 |
22 | ## How Standard Templates in `go-htmx` Align with LoB
23 |
24 | ### 1. Keeping Behavior Close to Markup
25 |
26 | #### By using standard HTML templates with go-htmx, the dynamic aspects of the UI are defined within the HTML itself:
27 |
28 | - **Template Logic in HTML**: Conditional rendering, loops, and data binding are expressed using template syntax directly in the HTML.
29 | - **HTMX Attributes in HTML**: HTMX behaviors are specified using attributes (e.g., hx-get, hx-post) directly on the elements they affect.
30 |
31 | #### Alignment with LoB:
32 |
33 | - **Direct Association**: The behavior (e.g., data fetching, event handling) is specified on the elements themselves, adhering to the LoB principle.
34 | - **No Hidden Logic**: There's no need to look elsewhere (like separate Go code generating HTML) to understand an element's behavior.
35 |
36 | ### 2. Separation of Concerns
37 |
38 | #### Using standard templates maintains a clear separation between:
39 |
40 | - **Presentation Layer**: HTML templates define the structure and layout.
41 | - **Application Logic**: Go code handles data processing, business logic, and passes data to templates.
42 |
43 | #### Alignment with LoB:
44 |
45 | - **Focused Responsibility**: The HTML templates are responsible for how data is presented, and any behavior (via HTMX attributes) is declared in the markup.
46 | - **Easier Reasoning**: Developers can reason about UI behavior by examining the templates, without needing to trace through Go code generating HTML.
47 |
48 | ### 3. Avoiding Mixed Logic in Go Code
49 |
50 | Frameworks like gomponents or gotempl often involve generating HTML directly from Go code, intertwining application logic with UI rendering.
51 |
52 | #### Alignment with LoB by Avoiding This Approach:
53 |
54 | - **Reducing Indirection**: By not generating HTML in Go code, go-htmx avoids separating the behavior from the elements it affects.
55 | - **Simplifying Debugging**: When issues arise, developers can inspect the HTML templates where the markup and behavior are declared together.
56 |
57 | #### Benefits of This Alignment
58 |
59 | - **Consistency with HTMX Philosophy**: Aligning with LoB makes go-htmx a natural fit for developers using HTMX, ensuring consistent design principles across the stack.
60 | - **Enhanced Developer Experience**: Developers can quickly understand and modify UI components without navigating between multiple files or layers of abstraction.
61 | - **Better Collaboration**: Designers and frontend developers can work on templates and understand behaviors without needing deep knowledge of Go code.
62 |
63 | ### Practical Example
64 |
65 | #### Using Standard Templates:
66 | ```gotemplate
67 |
68 |
69 |
{{.Data.UserName}}
70 |
71 | ```
72 | - **Behavior in Markup**: The `hx-get` attribute defines the behavior directly on the div.
73 | - **Data Binding in Markup**: The user's name is displayed using template syntax.
74 |
75 | #### Contrast with Code-Generated HTML:
76 | ```go
77 | // Using a hypothetical component library
78 | func UserCard(userID int, userName string) h.HTML {
79 | return h.Div(
80 | h.Class("user-card"),
81 | h.Attr("hx-get", fmt.Sprintf("/user/%d/details", userID)),
82 | h.Attr("hx-trigger", "click"),
83 | h.H2(h.Text(userName)),
84 | )
85 | }
86 | ```
87 | - Behavior Separated from Markup: The behavior is defined in Go code, not in the HTML structure.
88 | - Less Aligned with LoB: Understanding the UI requires reading the Go code that generates it.
89 |
90 | #### Conclusion
91 |
92 | By using standard HTML templates and embedding behaviors directly within the markup, `go-htmx` adheres to the Locality of Behavior principle promoted by HTMX. This approach ensures that:
93 |
94 | - The behavior affecting an element is declared alongside the element itself.
95 | - The separation of concerns is maintained between application logic and presentation.
96 | - Developers benefit from clearer, more maintainable code.
97 |
98 | ---
99 |
100 | ## In Summary:
101 |
102 | - **Alignment with LoB**: Yes, go-htmx aligns with the Locality of Behavior principle.
103 | - **Reasoning**: Using standard templates keeps behavior close to the elements, making it easier to understand and maintain.
104 | - **Advantages**: Enhances readability, maintainability, and developer experience, consistent with HTMX's design philosophy.
--------------------------------------------------------------------------------
/sse/sse.go:
--------------------------------------------------------------------------------
1 | package sse
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "strings"
7 | "sync"
8 | "time"
9 | )
10 |
11 | type (
12 | // Listener defines the interface for the receiving end.
13 | Listener interface {
14 | ID() string
15 | Chan() chan Envelope
16 | }
17 |
18 | // Envelope defines the interface for content that can be broadcast to clients.
19 | Envelope interface {
20 | String() string // Represent the envelope contents as a string for transmission.
21 | }
22 |
23 | // Manager defines the interface for managing clients and broadcasting messages.
24 | Manager interface {
25 | Send(message Envelope)
26 | Handle(w http.ResponseWriter, r *http.Request, cl Listener)
27 | Clients() []string
28 | }
29 |
30 | History interface {
31 | Add(message Envelope) // Add adds a message to the history.
32 | Send(c Listener) // Send sends the history to a client.
33 | }
34 | )
35 |
36 | type Client struct {
37 | id string
38 | ch chan Envelope
39 | }
40 |
41 | func NewClient(id string) Listener {
42 | return &Client{
43 | id: id,
44 | ch: make(chan Envelope, 50),
45 | }
46 | }
47 |
48 | func (c *Client) ID() string { return c.id }
49 | func (c *Client) Chan() chan Envelope { return c.ch }
50 |
51 | // Message represents a simple message implementation.
52 | type Message struct {
53 | Event string
54 | Time time.Time
55 | Data string
56 | }
57 |
58 | // NewMessage returns a new message instance.
59 | func NewMessage(data string) *Message {
60 | return &Message{
61 | Data: data,
62 | Time: time.Now(),
63 | }
64 | }
65 |
66 | // String returns the message as a string.
67 | func (m *Message) String() string {
68 | sb := strings.Builder{}
69 |
70 | if m.Event != "" {
71 | sb.WriteString(fmt.Sprintf("event: %s\n", m.Event))
72 | }
73 | sb.WriteString(fmt.Sprintf("data: %v\n\n", m.Data))
74 |
75 | return sb.String()
76 | }
77 |
78 | // WithEvent sets the event name for the message.
79 | func (m *Message) WithEvent(event string) Envelope {
80 | m.Event = event
81 | return m
82 | }
83 |
84 | // broadcastManager manages the clients and broadcasts messages to them.
85 | type broadcastManager struct {
86 | clients sync.Map
87 | broadcast chan Envelope
88 | workerPoolSize int
89 | messageHistory *history
90 | }
91 |
92 | // NewManager initializes and returns a new Manager instance.
93 | func NewManager(workerPoolSize int) Manager {
94 | manager := &broadcastManager{
95 | broadcast: make(chan Envelope),
96 | workerPoolSize: workerPoolSize,
97 | messageHistory: newHistory(10),
98 | }
99 |
100 | manager.startWorkers()
101 |
102 | return manager
103 | }
104 |
105 | // Send broadcasts a message to all connected clients.
106 | func (manager *broadcastManager) Send(message Envelope) {
107 | manager.broadcast <- message
108 | }
109 |
110 | // Handle sets up a new client and handles the connection.
111 | func (manager *broadcastManager) Handle(w http.ResponseWriter, r *http.Request, cl Listener) {
112 | manager.register(cl)
113 |
114 | w.Header().Set("Content-Type", "text/event-stream")
115 | w.Header().Set("Cache-Control", "no-cache")
116 | w.Header().Set("Connection", "keep-alive")
117 |
118 | // Send history to the newly connected client
119 | manager.messageHistory.Send(cl)
120 |
121 | for {
122 | select {
123 | case msg, ok := <-cl.Chan():
124 | if !ok {
125 | // If the channel is closed, return from the function
126 | return
127 | }
128 | _, err := fmt.Fprint(w, msg.String())
129 | if err != nil {
130 | // If an error occurs (e.g., client has disconnected), return from the function
131 | return
132 | }
133 | if flusher, ok := w.(http.Flusher); ok {
134 | flusher.Flush()
135 | }
136 |
137 | case <-r.Context().Done():
138 | manager.unregister(cl.ID())
139 | close(cl.Chan())
140 | return
141 | }
142 | }
143 | }
144 |
145 | // Clients method to list connected client IDs
146 | func (manager *broadcastManager) Clients() []string {
147 | var clients []string
148 | manager.clients.Range(func(key, value any) bool {
149 | id, ok := key.(string)
150 | if ok {
151 | clients = append(clients, id)
152 | }
153 | return true
154 | })
155 | return clients
156 | }
157 |
158 | // startWorkers starts worker goroutines for message broadcasting.
159 | func (manager *broadcastManager) startWorkers() {
160 | for i := 0; i < manager.workerPoolSize; i++ {
161 | go func() {
162 | for message := range manager.broadcast {
163 | manager.clients.Range(func(key, value any) bool {
164 | client, ok := value.(Listener)
165 | if !ok {
166 | return true // Continue iteration
167 | }
168 | select {
169 | case client.Chan() <- message:
170 | manager.messageHistory.Add(message)
171 | default:
172 | // If the client's channel is full, drop the message
173 | }
174 | return true // Continue iteration
175 | })
176 | }
177 | }()
178 | }
179 | }
180 |
181 | // register adds a client to the manager.
182 | func (manager *broadcastManager) register(client Listener) {
183 | manager.clients.Store(client.ID(), client)
184 | }
185 |
186 | // unregister removes a client from the manager.
187 | func (manager *broadcastManager) unregister(clientID string) {
188 | manager.clients.Delete(clientID)
189 | }
190 |
191 | type history struct {
192 | messages []Envelope
193 | maxSize int // Maximum number of messages to retain
194 | }
195 |
196 | func newHistory(maxSize int) *history {
197 | return &history{
198 | messages: []Envelope{},
199 | maxSize: maxSize,
200 | }
201 | }
202 |
203 | func (h *history) Add(message Envelope) {
204 | h.messages = append(h.messages, message)
205 | // Ensure history does not exceed maxSize
206 | if len(h.messages) > h.maxSize {
207 | // Remove the oldest messages to fit the maxSize
208 | h.messages = h.messages[len(h.messages)-h.maxSize:]
209 | }
210 | }
211 |
212 | func (h *history) Send(c Listener) {
213 | for _, msg := range h.messages {
214 | c.Chan() <- msg
215 | }
216 | }
217 |
--------------------------------------------------------------------------------
/swap_test.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "testing"
5 | "time"
6 | )
7 |
8 | // TestSwapper_DefaultValues tests the default values set by Swapper
9 | func TestSwapper_DefaultValues(t *testing.T) {
10 | swap := NewSwap()
11 |
12 | if swap.style != SwapInnerHTML {
13 | t.Errorf("expected default style to be SwapInnerHTML, got %v", swap.style)
14 | }
15 |
16 | // Add more tests for other default values if necessary
17 | }
18 |
19 | // TestStyle tests the Style method
20 | func TestStyle(t *testing.T) {
21 | swap := NewSwap().Style(SwapOuterHTML)
22 |
23 | if swap.style != SwapOuterHTML {
24 | t.Errorf("expected style to be SwapOuterHTML, got %v", swap.style)
25 | }
26 | }
27 |
28 | // TestString tests the String method
29 | func TestString(t *testing.T) {
30 | swap := NewSwap()
31 | expected := "innerHTML"
32 |
33 | if swap.String() != expected {
34 | t.Errorf("expected string output to be %v, got %v", expected, swap.String())
35 | }
36 |
37 | // Additional scenarios for String method can be added here
38 | }
39 |
40 | // TestTimingSwap tests the TimingSwap method
41 | func TestTimingSwap(t *testing.T) {
42 | duration := 100 * time.Millisecond
43 | swap := NewSwap().Swap(duration)
44 |
45 | if swap.timing == nil || swap.timing.duration != duration {
46 | t.Errorf("expected timing swap to be %v, got %v", duration, swap.timing.duration)
47 | }
48 | }
49 |
50 | // TestTimingSettle tests the TimingSettle method
51 | func TestTimingSettle(t *testing.T) {
52 | duration := 200 * time.Millisecond
53 | swap := NewSwap().Settle(duration)
54 |
55 | if swap.timing == nil || swap.timing.duration != duration {
56 | t.Errorf("expected timing settle to be %v, got %v", duration, swap.timing.duration)
57 | }
58 | }
59 |
60 | // TestTransition tests the Transition method
61 | func TestTransition(t *testing.T) {
62 | swap := NewSwap().Transition(true)
63 |
64 | if swap.transition == nil || *swap.transition != true {
65 | t.Errorf("expected transition to be true, got %v", swap.transition)
66 | }
67 | expected := `innerHTML transition:true`
68 | if swap.String() != expected {
69 | t.Errorf("expected string output to be %s, got %s", expected, swap.String())
70 | }
71 |
72 | }
73 |
74 | // TestIgnoreTitle tests the IgnoreTitle method
75 | func TestIgnoreTitle(t *testing.T) {
76 | swap := NewSwap().IgnoreTitle(true)
77 |
78 | if swap.ignoreTitle == nil || *swap.ignoreTitle != true {
79 | t.Errorf("expected ignoreTitle to be true, got %v", swap.ignoreTitle)
80 | }
81 |
82 | expected := `innerHTML ignoreTitle:true`
83 | if swap.String() != "innerHTML ignoreTitle:true" {
84 | t.Errorf("expected string output to be %s, got %s", expected, swap.String())
85 | }
86 | }
87 |
88 | // TestFocusScroll tests the FocusScroll method
89 | func TestFocusScroll(t *testing.T) {
90 | swap := NewSwap().FocusScroll(true)
91 |
92 | if swap.focusScroll == nil || *swap.focusScroll != true {
93 | t.Errorf("expected focusScroll to be true, got %v", swap.focusScroll)
94 | }
95 | }
96 |
97 | // TestScrollingScroll tests the ScrollingScroll method
98 | func TestScrollingScroll(t *testing.T) {
99 | swap := NewSwap().Scroll(SwapDirectionTop)
100 |
101 | if swap.scrolling == nil || swap.scrolling.direction != SwapDirectionTop || swap.scrolling.mode != ScrollingScroll {
102 | t.Errorf("expected scrolling mode to be ScrollingScroll and direction to be SwapDirectionTop, got mode: %v, direction: %v", swap.scrolling.mode, swap.scrolling.direction)
103 | }
104 | }
105 |
106 | // TestScrollingShow tests the ScrollingShow method
107 | func TestScrollingShow(t *testing.T) {
108 | swap := NewSwap().Show(SwapDirectionBottom)
109 |
110 | if swap.scrolling == nil || swap.scrolling.direction != SwapDirectionBottom || swap.scrolling.mode != ScrollingShow {
111 | t.Errorf("expected scrolling mode to be ScrollingShow and direction to be SwapDirectionBottom, got mode: %v, direction: %v", swap.scrolling.mode, swap.scrolling.direction)
112 | }
113 | }
114 |
115 | // TestScrollingScrollTop tests the ScrollingScrollTop method
116 | func TestScrollingScrollTop(t *testing.T) {
117 | target := "#element"
118 | swap := NewSwap().ScrollTop(target)
119 |
120 | if swap.scrolling == nil || swap.scrolling.direction != SwapDirectionTop || swap.scrolling.mode != ScrollingScroll || swap.scrolling.target != target {
121 | t.Errorf("expected scrolling mode to be ScrollingScroll, direction to be SwapDirectionTop, and target to be %v, got mode: %v, direction: %v, target: %v", target, swap.scrolling.mode, swap.scrolling.direction, swap.scrolling.target)
122 | }
123 | }
124 |
125 | // TestScrollingScrollBottom tests the ScrollingScrollBottom method
126 | func TestScrollingScrollBottom(t *testing.T) {
127 | target := "#element"
128 | swap := NewSwap().ScrollBottom(target)
129 |
130 | if swap.scrolling == nil || swap.scrolling.direction != SwapDirectionBottom || swap.scrolling.mode != ScrollingScroll || swap.scrolling.target != target {
131 | t.Errorf("expected scrolling mode to be ScrollingScroll, direction to be SwapDirectionBottom, and target to be %v, got mode: %v, direction: %v, target: %v", target, swap.scrolling.mode, swap.scrolling.direction, swap.scrolling.target)
132 | }
133 | }
134 |
135 | // TestScrollingShowTop tests the ScrollingShowTop method
136 | func TestScrollingShowTop(t *testing.T) {
137 | target := "#element"
138 | swap := NewSwap().ShowTop(target)
139 |
140 | if swap.scrolling == nil || swap.scrolling.direction != SwapDirectionTop || swap.scrolling.mode != ScrollingShow || swap.scrolling.target != target {
141 | t.Errorf("expected scrolling mode to be ScrollingShow, direction to be SwapDirectionTop, and target to be %v, got mode: %v, direction: %v, target: %v", target, swap.scrolling.mode, swap.scrolling.direction, swap.scrolling.target)
142 | }
143 | }
144 |
145 | // TestScrollingShowBottom tests the ScrollingShowBottom method
146 | func TestScrollingShowBottom(t *testing.T) {
147 | target := "#element"
148 | swap := NewSwap().ShowBottom(target)
149 |
150 | if swap.scrolling == nil || swap.scrolling.direction != SwapDirectionBottom || swap.scrolling.mode != ScrollingShow || swap.scrolling.target != target {
151 | t.Errorf("expected scrolling mode to be ScrollingShow, direction to be SwapDirectionBottom, and target to be %v, got mode: %v, direction: %v, target: %v", target, swap.scrolling.mode, swap.scrolling.direction, swap.scrolling.target)
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/swap.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "time"
7 | )
8 |
9 | type Swap struct {
10 | style SwapStyle
11 | transition *bool
12 | timing *SwapTiming
13 | scrolling *SwapScrolling
14 | ignoreTitle *bool
15 | focusScroll *bool
16 | }
17 |
18 | type SwapTiming struct {
19 | mode SwapTimingMode
20 | duration time.Duration
21 | }
22 |
23 | func (s *SwapTiming) String() string {
24 | var out string
25 |
26 | out = string(s.mode)
27 |
28 | if s.duration != 0 {
29 | out += ":" + s.duration.String()
30 | }
31 |
32 | return out
33 | }
34 |
35 | type SwapScrolling struct {
36 | mode SwapScrollingMode
37 | target string
38 | direction SwapDirection
39 | }
40 |
41 | func (s *SwapScrolling) String() string {
42 | var out string
43 |
44 | out = string(s.mode)
45 |
46 | if s.target != "" {
47 | out += ":" + s.target
48 | }
49 |
50 | if s.direction != "" {
51 | out += ":" + s.direction.String()
52 | }
53 |
54 | return out
55 | }
56 |
57 | // NewSwap returns a new Swap
58 | func NewSwap() *Swap {
59 | return &Swap{
60 | style: SwapInnerHTML,
61 | }
62 | }
63 |
64 | // Style sets the style of the swap, default is innerHTML and can be changed in htmx.config.defaultSwapStyle
65 | func (s *Swap) Style(style SwapStyle) *Swap {
66 | s.style = style
67 | return s
68 | }
69 |
70 | // setScrolling sets the scrolling behavior
71 | func (s *Swap) setScrolling(mode SwapScrollingMode, direction SwapDirection, target ...string) *Swap {
72 | scrolling := &SwapScrolling{
73 | mode: mode,
74 | direction: direction,
75 | }
76 |
77 | if len(target) > 0 {
78 | scrolling.target = target[0]
79 | }
80 |
81 | s.scrolling = scrolling
82 | return s
83 | }
84 |
85 | // Scroll sets the scrolling behavior to scroll to the top or bottom
86 | func (s *Swap) Scroll(direction SwapDirection, target ...string) *Swap {
87 | return s.setScrolling(ScrollingScroll, direction, target...)
88 | }
89 |
90 | // ScrollTop sets the scrolling behavior to scroll to the top of the target element
91 | func (s *Swap) ScrollTop(target ...string) *Swap {
92 | return s.Scroll(SwapDirectionTop, target...)
93 | }
94 |
95 | // ScrollBottom sets the scrolling behavior to scroll to the bottom of the target element
96 | func (s *Swap) ScrollBottom(target ...string) *Swap {
97 | return s.Scroll(SwapDirectionBottom, target...)
98 | }
99 |
100 | // Show sets the scrolling behavior to scroll to the top or bottom
101 | func (s *Swap) Show(direction SwapDirection, target ...string) *Swap {
102 | return s.setScrolling(ScrollingShow, direction, target...)
103 | }
104 |
105 | // ShowTop sets the scrolling behavior to scroll to the top of the target element
106 | func (s *Swap) ShowTop(target ...string) *Swap {
107 | return s.Show(SwapDirectionTop, target...)
108 | }
109 |
110 | // ShowBottom sets the scrolling behavior to scroll to the bottom of the target element
111 | func (s *Swap) ShowBottom(target ...string) *Swap {
112 | return s.Show(SwapDirectionBottom, target...)
113 | }
114 |
115 | // setTiming modifies the amount of time that htmx will wait after receiving a response to swap or settle the content
116 | func (s *Swap) setTiming(mode SwapTimingMode, swap ...time.Duration) *Swap {
117 | var duration time.Duration
118 | if len(swap) > 0 {
119 | duration = swap[0]
120 | } else {
121 | switch mode {
122 | case TimingSwap:
123 | duration = DefaultSwapDuration
124 | case TimingSettle:
125 | duration = DefaultSettleDelay
126 | }
127 | }
128 |
129 | s.timing = &SwapTiming{
130 | mode: mode,
131 | duration: duration,
132 | }
133 | return s
134 | }
135 |
136 | // Swap modifies the amount of time that htmx will wait after receiving a response to swap the content
137 | func (s *Swap) Swap(swap ...time.Duration) *Swap {
138 | return s.setTiming(TimingSwap, swap...)
139 | }
140 |
141 | // Settle modifies the amount of time that htmx will wait after receiving a response to settle the content
142 | func (s *Swap) Settle(swap ...time.Duration) *Swap {
143 | return s.setTiming(TimingSettle, swap...)
144 | }
145 |
146 | // Transition enables or disables the transition
147 | // see : https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
148 | func (s *Swap) Transition(transition bool) *Swap {
149 | s.transition = &transition
150 | return s
151 | }
152 |
153 | // IgnoreTitle enables or disables the Title
154 | func (s *Swap) IgnoreTitle(ignoreTitle bool) *Swap {
155 | s.ignoreTitle = &ignoreTitle
156 | return s
157 | }
158 |
159 | // FocusScroll enables or disables the focus scroll behaviour
160 | func (s *Swap) FocusScroll(focusScroll bool) *Swap {
161 | s.focusScroll = &focusScroll
162 | return s
163 | }
164 |
165 | // String returns the string representation of the Swap
166 | func (s *Swap) String() string {
167 | var parts []string
168 |
169 | parts = append(parts, string(s.style))
170 |
171 | if s.scrolling != nil {
172 | parts = append(parts, s.scrolling.String())
173 | }
174 |
175 | if s.transition != nil {
176 | parts = append(parts, fmt.Sprintf("transition:%s", HxBoolToStr(*s.transition)))
177 | }
178 |
179 | if s.ignoreTitle != nil {
180 | parts = append(parts, fmt.Sprintf("ignoreTitle:%s", HxBoolToStr(*s.ignoreTitle)))
181 | }
182 |
183 | if s.focusScroll != nil {
184 | parts = append(parts, fmt.Sprintf("focus-scroll:%s", HxBoolToStr(*s.focusScroll)))
185 | }
186 |
187 | if s.timing != nil {
188 | parts = append(parts, s.timing.String())
189 | }
190 |
191 | return strings.Join(parts, " ")
192 | }
193 |
194 | const (
195 | // SwapInnerHTML replaces the inner html of the target element
196 | SwapInnerHTML SwapStyle = "innerHTML"
197 |
198 | // SwapOuterHTML replaces the entire target element with the response
199 | SwapOuterHTML SwapStyle = "outerHTML"
200 |
201 | // SwapBeforeBegin insert the response before the target element
202 | SwapBeforeBegin SwapStyle = "beforebegin"
203 |
204 | // SwapAfterBegin insert the response before the first child of the target element
205 | SwapAfterBegin SwapStyle = "afterbegin"
206 |
207 | // SwapBeforeEnd insert the response after the last child of the target element
208 | SwapBeforeEnd SwapStyle = "beforeend"
209 |
210 | // SwapAfterEnd insert the response after the target element
211 | SwapAfterEnd SwapStyle = "afterend"
212 |
213 | // SwapDelete deletes the target element regardless of the response
214 | SwapDelete SwapStyle = "delete"
215 |
216 | // SwapNone does not append content from response (out of band items will still be processed).
217 | SwapNone SwapStyle = "none"
218 | )
219 |
220 | type SwapStyle string
221 |
222 | func (s SwapStyle) String() string {
223 | return string(s)
224 | }
225 |
226 | const (
227 | // ScrollingScroll You can also change the scrolling behavior of the target element by using the scroll and show modifiers, both of which take the values top and bottom
228 | ScrollingScroll SwapScrollingMode = "scroll"
229 |
230 | // ScrollingShow You can also change the scrolling behavior of the target element by using the scroll and show modifiers, both of which take the values top and bottom
231 | ScrollingShow SwapScrollingMode = "show"
232 | )
233 |
234 | type SwapScrollingMode string
235 |
236 | func (s SwapScrollingMode) String() string {
237 | return string(s)
238 | }
239 |
240 | const (
241 | // TimingSwap You can modify the amount of time that htmx will wait after receiving a response to swap the content by including a swap modifier
242 | TimingSwap SwapTimingMode = "swap"
243 |
244 | // TimingSettle you can modify the time between the swap and the settle logic by including a settle modifier:
245 | TimingSettle SwapTimingMode = "settle"
246 | )
247 |
248 | // SwapTimingMode modifies the amount of time that htmx will wait after receiving a response to swap or settle the content
249 | type SwapTimingMode string
250 |
251 | func (s SwapTimingMode) String() string {
252 | return string(s)
253 | }
254 |
255 | const (
256 | SwapDirectionTop SwapDirection = "top"
257 | SwapDirectionBottom SwapDirection = "bottom"
258 | )
259 |
260 | // SwapDirection modifies the scrolling behavior of the target element
261 | type SwapDirection string
262 |
263 | func (s SwapDirection) String() string {
264 | return string(s)
265 | }
266 |
--------------------------------------------------------------------------------
/htmx_test.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "encoding/json"
5 | "io"
6 | "net/http"
7 | "net/http/httptest"
8 | "testing"
9 | "time"
10 | )
11 |
12 | var (
13 | location = &LocationInput{
14 | Source: "source",
15 | Event: "",
16 | Handler: "",
17 | Target: "http://new-url.com",
18 | Swap: "",
19 | Values: nil,
20 | Header: nil,
21 | }
22 | pushURL = "http://push-url.com"
23 | redirect = "http://redirect.com"
24 | refresh = true
25 | replaceURL = "http://replace-url.com"
26 | reSwap = "#reSwap"
27 | reTarget = "#reTarget"
28 | reSelect = "#reSelect"
29 | trigger = "#trigger"
30 | triggerAfterSettle = "#triggerAfterSettle"
31 | triggerAfterSwap = "#triggerAfterSwap"
32 |
33 | reswapWithObject = NewSwap().ScrollTop().Settle(1 * time.Second)
34 | )
35 |
36 | func TestNew(t *testing.T) {
37 | h := New()
38 | if h == nil {
39 | t.Errorf("expected htmx to be initialized")
40 | }
41 |
42 | w := &dummyWriter{
43 | Writer: &httptest.ResponseRecorder{},
44 | }
45 | r := &http.Request{
46 | Header: http.Header{},
47 | }
48 | r.Header.Set("HX-Request", "true")
49 | r.Header.Set("HX-Boosted", "true")
50 | r.Header.Set("HX-History-Restore-Request", "false")
51 |
52 | handler := h.NewHandler(w, r)
53 | if handler == nil {
54 | t.Errorf("expected handler to be initialized")
55 | }
56 |
57 | equalBool(t, true, handler.IsHxRequest())
58 | equalBool(t, true, handler.IsHxBoosted())
59 | equalBool(t, false, handler.IsHxHistoryRestoreRequest())
60 | equalBool(t, true, handler.RenderPartial())
61 |
62 | _ = handler.Location(location)
63 | handler.PushURL(pushURL)
64 | handler.Redirect(redirect)
65 | handler.Refresh(refresh)
66 | handler.ReplaceURL(replaceURL)
67 | handler.ReSwap(reSwap)
68 | handler.ReTarget(reTarget)
69 | handler.ReSelect(reSelect)
70 | handler.Trigger(trigger)
71 | handler.TriggerAfterSettle(triggerAfterSettle)
72 | handler.TriggerAfterSwap(triggerAfterSwap)
73 | handler.WriteHeader(http.StatusAccepted)
74 |
75 | j, _ := json.Marshal(location)
76 | equal(t, string(j), handler.ResponseHeader(HXLocation))
77 | equal(t, pushURL, handler.ResponseHeader(HXPushUrl))
78 | equal(t, redirect, handler.ResponseHeader(HXRedirect))
79 | equal(t, HxBoolToStr(refresh), handler.ResponseHeader(HXRefresh))
80 | equal(t, replaceURL, handler.ResponseHeader(HXReplaceUrl))
81 | equal(t, reSwap, handler.ResponseHeader(HXReswap))
82 | equal(t, reTarget, handler.ResponseHeader(HXRetarget))
83 | equal(t, reSelect, handler.ResponseHeader(HXReselect))
84 | equal(t, trigger, handler.ResponseHeader(HXTrigger))
85 | equal(t, triggerAfterSwap, handler.ResponseHeader(HXTriggerAfterSwap))
86 | equal(t, triggerAfterSettle, handler.ResponseHeader(HXTriggerAfterSettle))
87 |
88 | handler.ReSwapWithObject(reswapWithObject)
89 | handler.TriggerWithObject(NewTrigger().AddEvent(trigger))
90 | handler.TriggerAfterSettleWithObject(NewTrigger().AddEvent(triggerAfterSettle))
91 | handler.TriggerAfterSwapWithObject(NewTrigger().AddEvent(triggerAfterSwap))
92 |
93 | equal(t, reswapWithObject.String(), handler.ResponseHeader(HXReswap))
94 |
95 | head := handler.Header()
96 | equal(t, "true", head.Get("Hx-Request"))
97 |
98 | handler.StopPolling()
99 |
100 | req := handler.Request()
101 |
102 | equalBool(t, req.HxBoosted, handler.IsHxBoosted())
103 | equalBool(t, req.HxHistoryRestoreRequest, handler.IsHxHistoryRestoreRequest())
104 | equalBool(t, req.HxRequest, handler.IsHxRequest())
105 |
106 | equalBool(t, req.HxBoosted, IsHxBoosted(r))
107 | equalBool(t, req.HxHistoryRestoreRequest, IsHxHistoryRestoreRequest(r))
108 | equalBool(t, req.HxRequest, IsHxRequest(r))
109 |
110 | i, _ := handler.Write([]byte("hi"))
111 | equalInt(t, 2, i)
112 | }
113 |
114 | func TestNoRouter(t *testing.T) {
115 | h := New()
116 |
117 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118 | handler := h.NewHandler(w, r)
119 |
120 | _ = handler.Location(location)
121 | handler.PushURL(pushURL)
122 | handler.Redirect(redirect)
123 | handler.Refresh(true)
124 | handler.ReplaceURL(replaceURL)
125 | handler.ReSwap(reSwap)
126 | handler.ReTarget(reTarget)
127 | handler.Trigger(trigger)
128 | handler.TriggerAfterSettle(triggerAfterSettle)
129 | handler.TriggerAfterSwap(triggerAfterSwap)
130 | handler.WriteHeader(http.StatusAccepted)
131 |
132 | _, err := handler.Write([]byte("hi"))
133 | if err != nil {
134 | t.Error(err)
135 | }
136 | }))
137 | defer svr.Close()
138 |
139 | resp, err := http.Get(svr.URL)
140 | if err != nil {
141 | t.Error("an error occurred while making the request")
142 | return
143 | }
144 | defer resp.Body.Close()
145 |
146 | _, err = io.ReadAll(resp.Body)
147 | if err != nil {
148 | t.Error("an error occurred when reading the response")
149 | }
150 |
151 | j, _ := json.Marshal(location)
152 | equal(t, string(j), resp.Header.Get(HXLocation.String()))
153 | equal(t, pushURL, resp.Header.Get(HXPushUrl.String()))
154 | equal(t, redirect, resp.Header.Get(HXRedirect.String()))
155 | equal(t, HxBoolToStr(refresh), resp.Header.Get(HXRefresh.String()))
156 | equal(t, replaceURL, resp.Header.Get(HXReplaceUrl.String()))
157 | equal(t, reSwap, resp.Header.Get(HXReswap.String()))
158 | equal(t, reTarget, resp.Header.Get(HXRetarget.String()))
159 | equal(t, trigger, resp.Header.Get(HXTrigger.String()))
160 | equal(t, triggerAfterSwap, resp.Header.Get(HXTriggerAfterSwap.String()))
161 | equal(t, triggerAfterSettle, resp.Header.Get(HXTriggerAfterSettle.String()))
162 | equalInt(t, http.StatusAccepted, resp.StatusCode)
163 | }
164 |
165 | func TestHxResponseKey_String(t *testing.T) {
166 | equal(t, "HX-Location", HXLocation.String())
167 | equal(t, "HX-Push-Url", HXPushUrl.String())
168 | equal(t, "HX-Redirect", HXRedirect.String())
169 | equal(t, "HX-Refresh", HXRefresh.String())
170 | equal(t, "HX-Replace-Url", HXReplaceUrl.String())
171 | equal(t, "HX-Reswap", HXReswap.String())
172 | equal(t, "HX-Retarget", HXRetarget.String())
173 | equal(t, "HX-Reselect", HXReselect.String())
174 | equal(t, "HX-Trigger", HXTrigger.String())
175 | equal(t, "HX-Trigger-After-Settle", HXTriggerAfterSettle.String())
176 | equal(t, "HX-Trigger-After-Swap", HXTriggerAfterSwap.String())
177 | }
178 |
179 | func TestStopPolling(t *testing.T) {
180 | h := New()
181 |
182 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183 | handler := h.NewHandler(w, r)
184 |
185 | _ = handler.Location(location)
186 | handler.WriteHeader(StatusStopPolling)
187 |
188 | _, err := handler.Write([]byte("hi"))
189 | if err != nil {
190 | t.Error(err)
191 | }
192 | }))
193 | defer svr.Close()
194 |
195 | resp, err := http.Get(svr.URL)
196 | if err != nil {
197 | t.Error("an error occurred while making the request")
198 | return
199 | }
200 | defer resp.Body.Close()
201 |
202 | equalInt(t, StatusStopPolling, resp.StatusCode)
203 | }
204 |
205 | func TestSwap(t *testing.T) {
206 | h := New()
207 |
208 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
209 | handler := h.NewHandler(w, r)
210 |
211 | _ = handler.Location(location)
212 | handler.ReSwapWithObject(NewSwap().ScrollTop().Settle(1 * time.Second))
213 |
214 | _, err := handler.Write([]byte("hi"))
215 | if err != nil {
216 | t.Error(err)
217 | }
218 | }))
219 | defer svr.Close()
220 |
221 | resp, err := http.Get(svr.URL)
222 | if err != nil {
223 | t.Error("an error occurred while making the request")
224 | return
225 | }
226 | defer resp.Body.Close()
227 |
228 | equal(t, "innerHTML scroll:top settle:1s", resp.Header.Get(HXReswap.String()))
229 | }
230 |
231 | func TestHxStrToBool(t *testing.T) {
232 | equalBool(t, true, HxStrToBool("true"))
233 | equalBool(t, false, HxStrToBool("false"))
234 | equalBool(t, false, HxStrToBool("not a bool"))
235 | }
236 |
237 | func TestHxBoolToStr(t *testing.T) {
238 | equal(t, "true", HxBoolToStr(true))
239 | equal(t, "false", HxBoolToStr(false))
240 | }
241 |
242 | type dummyWriter struct {
243 | io.Writer
244 | }
245 |
246 | func (dummyWriter) Header() http.Header {
247 | h := http.Header{}
248 | h.Set("HX-Request", "true")
249 | return h
250 | }
251 | func (dummyWriter) WriteHeader(int) {}
252 |
253 | func equalBool(t *testing.T, expected, actual bool) {
254 | if expected != actual {
255 | t.Errorf("expected %t, got %t", expected, actual)
256 | }
257 | }
258 |
259 | func equal(t *testing.T, expected, actual string) {
260 | if expected != actual {
261 | t.Errorf("expected %s, got %s", expected, actual)
262 | }
263 | }
264 |
265 | func equalInt(t *testing.T, expected, actual int) {
266 | if expected != actual {
267 | t.Errorf("expected %d, got %d", expected, actual)
268 | }
269 | }
270 |
--------------------------------------------------------------------------------
/component.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "crypto/sha256"
7 | "encoding/hex"
8 | "errors"
9 | "html/template"
10 | "io/fs"
11 | "net/url"
12 | "os"
13 | "path/filepath"
14 | "sort"
15 | "strings"
16 | "sync"
17 | )
18 |
19 | var (
20 | DefaultTemplateFuncs = template.FuncMap{}
21 | UseTemplateCache = true
22 | templateCache = sync.Map{} // Cache for parsed templates
23 | )
24 |
25 | type (
26 | RenderableComponent interface {
27 | Render(ctx context.Context) (template.HTML, error)
28 | Wrap(renderer RenderableComponent, target string) RenderableComponent
29 | With(r RenderableComponent, target string) RenderableComponent
30 | Attach(target string) RenderableComponent
31 | SetData(input map[string]any) RenderableComponent
32 | AddData(key string, value any) RenderableComponent
33 | SetGlobalData(input map[string]any) RenderableComponent
34 | AddGlobalData(key string, value any) RenderableComponent
35 | AddTemplateFunction(name string, function interface{}) RenderableComponent
36 | AddTemplateFunctions(funcs template.FuncMap) RenderableComponent
37 | SetURL(url *url.URL)
38 | Reset() *Component
39 |
40 | data() map[string]any
41 | injectData(input map[string]any)
42 | injectGlobalData(input map[string]any)
43 | addPartial(key string, value any)
44 | partials() map[string]RenderableComponent
45 | isWrapped() bool
46 | wrapper() RenderableComponent
47 | target() string
48 | }
49 |
50 | Component struct {
51 | templateData map[string]any
52 | with map[string]RenderableComponent
53 | partial map[string]any
54 | globalData map[string]any
55 | wrappedRenderer RenderableComponent
56 | wrappedTarget string
57 | templates []string
58 | url *url.URL
59 | functions template.FuncMap
60 | fs fs.FS
61 | }
62 | )
63 |
64 | func NewComponent(templates ...string) *Component {
65 | return &Component{
66 | templateData: make(map[string]any),
67 | functions: make(template.FuncMap),
68 | partial: make(map[string]any),
69 | with: make(map[string]RenderableComponent),
70 | templates: templates,
71 | fs: os.DirFS("./"),
72 | }
73 | }
74 |
75 | // FS sets the filesystem to load templates from, this allows for embedding templates into the go binary.
76 | func (c *Component) FS(fsys fs.FS) *Component {
77 | c.fs = fsys
78 | return c
79 | }
80 |
81 | // Render renders the given templates with the given data
82 | // it has all the default template functions and the additional template functions
83 | // that are added with AddTemplateFunction
84 | func (c *Component) Render(ctx context.Context) (template.HTML, error) {
85 | // Check for circular references
86 | if ctx.Value(c) != nil {
87 | return "", errors.New("circular reference detected in partials")
88 | }
89 |
90 | // Add current component to context
91 | ctx = context.WithValue(ctx, c, true)
92 |
93 | for key, value := range c.partials() {
94 | value.injectData(c.templateData)
95 | value.injectGlobalData(c.globalData)
96 |
97 | ch, err := value.Render(ctx)
98 | if err != nil {
99 | return "", err
100 | }
101 | c.addPartial(key, ch)
102 | }
103 |
104 | //get the name of the first template file
105 | if len(c.templates) == 0 {
106 | return "", errors.New("no templates provided for rendering")
107 | }
108 |
109 | return c.renderNamed(ctx, filepath.Base(c.templates[0]), c.templates, c.templateData)
110 | }
111 |
112 | // renderNamed renders the given templates with the given data
113 | // it has all the default template functions and the additional template functions
114 | // that are added with AddTemplateFunction
115 | func (c *Component) renderNamed(ctx context.Context, name string, templates []string, input map[string]any) (template.HTML, error) {
116 | if len(templates) == 0 {
117 | return "", nil
118 | }
119 |
120 | var err error
121 | functions := make(template.FuncMap)
122 | for key, value := range DefaultTemplateFuncs {
123 | functions[key] = value
124 | }
125 |
126 | if c.functions != nil {
127 | for key, value := range c.functions {
128 | functions[key] = value
129 | }
130 | }
131 |
132 | cacheKey := generateCacheKey(templates, functions)
133 | tmpl, cached := templateCache.Load(cacheKey)
134 | if !cached || !UseTemplateCache {
135 | // Parse and cache template as before
136 | tmpl, err = template.New(name).Funcs(functions).ParseFS(c.fs, templates...)
137 | if err != nil {
138 | return "", err
139 | }
140 | templateCache.Store(cacheKey, tmpl)
141 | }
142 |
143 | data := struct {
144 | Ctx context.Context
145 | Data map[string]any
146 | Global map[string]any
147 | Partials map[string]any
148 | URL *url.URL
149 | }{
150 | Ctx: ctx,
151 | Data: input,
152 | Global: c.globalData,
153 | Partials: c.partial,
154 | URL: c.url,
155 | }
156 |
157 | if t, ok := tmpl.(*template.Template); ok {
158 | var buf bytes.Buffer
159 | err = t.Execute(&buf, data)
160 | if err != nil {
161 | return "", err
162 | }
163 |
164 | return template.HTML(buf.String()), nil // Return rendered content
165 | }
166 |
167 | return "", errors.New("template is not a *template.Template")
168 | }
169 |
170 | // Wrap wraps the component with the given renderer
171 | func (c *Component) Wrap(renderer RenderableComponent, target string) RenderableComponent {
172 | c.wrappedRenderer = renderer
173 | c.wrappedTarget = target
174 |
175 | return c
176 | }
177 |
178 | // With adds a partial to the component
179 | func (c *Component) With(r RenderableComponent, target string) RenderableComponent {
180 | if c.with == nil {
181 | c.with = make(map[string]RenderableComponent)
182 | }
183 |
184 | if c.url != nil {
185 | r.SetURL(c.url)
186 | }
187 |
188 | c.with[target] = r
189 |
190 | return c
191 | }
192 |
193 | // Attach adds a template to the main component but doesn't pre-render it
194 | func (c *Component) Attach(target string) RenderableComponent {
195 | if c.templates == nil {
196 | c.templates = make([]string, 0)
197 | }
198 |
199 | c.templates = append(c.templates, target)
200 | return c
201 | }
202 |
203 | func (c *Component) AddTemplateFunction(name string, function interface{}) RenderableComponent {
204 | if c.functions == nil {
205 | c.functions = make(template.FuncMap)
206 | }
207 |
208 | c.functions[name] = function
209 |
210 | return c
211 | }
212 |
213 | func (c *Component) AddTemplateFunctions(funcs template.FuncMap) RenderableComponent {
214 | if c.functions == nil {
215 | c.functions = make(template.FuncMap)
216 | }
217 |
218 | for key, value := range funcs {
219 | c.functions[key] = value
220 | }
221 |
222 | return c
223 | }
224 |
225 | func (c *Component) SetGlobalData(input map[string]any) RenderableComponent {
226 | if c.globalData == nil {
227 | c.globalData = make(map[string]any)
228 | }
229 |
230 | c.globalData = input
231 |
232 | return c
233 | }
234 |
235 | func (c *Component) AddGlobalData(key string, value any) RenderableComponent {
236 | if c.globalData == nil {
237 | c.globalData = make(map[string]any)
238 | }
239 |
240 | c.globalData[key] = value
241 |
242 | return c
243 | }
244 |
245 | // SetData adds data to the component
246 | func (c *Component) SetData(input map[string]any) RenderableComponent {
247 | if c.templateData == nil {
248 | c.templateData = make(map[string]any)
249 | }
250 |
251 | c.templateData = input
252 |
253 | return c
254 | }
255 |
256 | func (c *Component) AddData(key string, value any) RenderableComponent {
257 | if c.templateData == nil {
258 | c.templateData = make(map[string]any)
259 | }
260 |
261 | c.templateData[key] = value
262 |
263 | return c
264 | }
265 |
266 | func (c *Component) SetURL(url *url.URL) {
267 | c.url = url
268 |
269 | // Recursively set the URL for all partials
270 | for _, partial := range c.with {
271 | partial.SetURL(url)
272 | }
273 | }
274 |
275 | // isWrapped returns true if the component is wrapped
276 | func (c *Component) isWrapped() bool {
277 | return c.wrappedRenderer != nil
278 | }
279 |
280 | // wrapper returns the wrapped renderer
281 | func (c *Component) wrapper() RenderableComponent {
282 | return c.wrappedRenderer
283 | }
284 |
285 | // target returns the target
286 | func (c *Component) target() string {
287 | return c.wrappedTarget
288 | }
289 |
290 | // partials returns the partials
291 | func (c *Component) partials() map[string]RenderableComponent {
292 | return c.with
293 | }
294 |
295 | // injectData injects the input data into the template data
296 | func (c *Component) injectData(input map[string]any) {
297 | for key, value := range input {
298 | if _, ok := c.templateData[key]; !ok {
299 | c.templateData[key] = value
300 | }
301 | }
302 | }
303 |
304 | func (c *Component) injectGlobalData(input map[string]any) {
305 | if c.globalData == nil {
306 | c.globalData = make(map[string]any)
307 | }
308 |
309 | for key, value := range input {
310 | if _, ok := c.globalData[key]; !ok {
311 | c.globalData[key] = value
312 | }
313 | }
314 | }
315 |
316 | // addPartial adds a partial to the component
317 | func (c *Component) addPartial(key string, value any) {
318 | c.partial[key] = value
319 | }
320 |
321 | // data returns the template data
322 | func (c *Component) data() map[string]any {
323 | return c.templateData
324 | }
325 |
326 | func (c *Component) Reset() *Component {
327 | c.templateData = make(map[string]any)
328 | c.globalData = make(map[string]any)
329 | c.partial = make(map[string]any)
330 | c.with = make(map[string]RenderableComponent)
331 | c.url = nil
332 |
333 | return c
334 | }
335 |
336 | // Generate a hash of the function names to include in the cache key
337 | func generateCacheKey(templates []string, funcs template.FuncMap) string {
338 | var funcNames []string
339 | for name := range funcs {
340 | funcNames = append(funcNames, name)
341 | }
342 | // Sort function names to ensure consistent ordering
343 | sort.Strings(funcNames)
344 | hash := sha256.Sum256([]byte(strings.Join(funcNames, ",")))
345 | return templates[0] + ":" + hex.EncodeToString(hash[:])
346 | }
347 |
--------------------------------------------------------------------------------
/handler.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "html/template"
7 | "net/http"
8 | )
9 |
10 | type (
11 | Handler struct {
12 | log Logger
13 | w http.ResponseWriter
14 | r *http.Request
15 | request HxRequestHeader
16 | response *HxResponseHeader
17 | }
18 | )
19 |
20 | const (
21 | // StatusStopPolling is the status code that will stop htmx from polling
22 | StatusStopPolling = 286
23 | )
24 |
25 | // IsHxRequest returns true if the request is a htmx request.
26 | func (h *Handler) IsHxRequest() bool {
27 | return h.request.HxRequest
28 | }
29 |
30 | // IsHxBoosted returns true if the request is a htmx request and the request is boosted
31 | func (h *Handler) IsHxBoosted() bool {
32 | return h.request.HxBoosted
33 | }
34 |
35 | // IsHxHistoryRestoreRequest returns true if the request is a htmx request and the request is a history restore request
36 | func (h *Handler) IsHxHistoryRestoreRequest() bool {
37 | return h.request.HxHistoryRestoreRequest
38 | }
39 |
40 | // RenderPartial returns true if the request is an HTMX request that is either boosted or a standard request,
41 | // provided it is not a history restore request.
42 | func (h *Handler) RenderPartial() bool {
43 | return (h.request.HxRequest || h.request.HxBoosted) && !h.request.HxHistoryRestoreRequest
44 | }
45 |
46 | // Write writes the data to the connection as part of an HTTP reply.
47 | func (h *Handler) Write(data []byte) (n int, err error) {
48 | return h.w.Write(data)
49 | }
50 |
51 | // WriteHTML is a helper that writes HTML data to the connection.
52 | func (h *Handler) WriteHTML(html template.HTML) (n int, err error) {
53 | return h.Write([]byte(html))
54 | }
55 |
56 | // WriteString is a helper that writes string data to the connection.
57 | func (h *Handler) WriteString(s string) (n int, err error) {
58 | return h.Write([]byte(s))
59 | }
60 |
61 | // WriteJSON is a helper that writes json data to the connection.
62 | func (h *Handler) WriteJSON(data any) (n int, err error) {
63 | payload, err := json.Marshal(data)
64 | if err != nil {
65 | return 0, err
66 | }
67 |
68 | return h.Write(payload)
69 | }
70 |
71 | // JustWrite writes the data to the connection as part of an HTTP reply.
72 | func (h *Handler) JustWrite(data []byte) {
73 | _, err := h.Write(data)
74 | if err != nil {
75 | h.log.Warn(err.Error())
76 | }
77 | }
78 |
79 | // JustWriteHTML is a helper that writes HTML data to the connection.
80 | func (h *Handler) JustWriteHTML(html template.HTML) {
81 | _, err := h.WriteHTML(html)
82 | if err != nil {
83 | h.log.Warn(err.Error())
84 | }
85 | }
86 |
87 | // JustWriteString is a helper that writes string data to the connection.
88 | func (h *Handler) JustWriteString(s string) {
89 | _, err := h.WriteString(s)
90 | if err != nil {
91 | h.log.Warn(err.Error())
92 | }
93 | }
94 |
95 | // JustWriteJSON is a helper that writes json data to the connection.
96 | func (h *Handler) JustWriteJSON(data any) {
97 | _, err := h.WriteJSON(data)
98 | if err != nil {
99 | h.log.Warn(err.Error())
100 | }
101 | }
102 |
103 | // MustWrite writes the data to the connection as part of an HTTP reply.
104 | func (h *Handler) MustWrite(data []byte) {
105 | _, err := h.Write(data)
106 | if err != nil {
107 | panic(err)
108 | }
109 | }
110 |
111 | // MustWriteHTML is a helper that writes HTML data to the connection.
112 | func (h *Handler) MustWriteHTML(html template.HTML) {
113 | _, err := h.WriteHTML(html)
114 | if err != nil {
115 | panic(err)
116 | }
117 | }
118 |
119 | // MustWriteString is a helper that writes string data to the connection.
120 | func (h *Handler) MustWriteString(s string) {
121 | _, err := h.WriteString(s)
122 | if err != nil {
123 | panic(err)
124 | }
125 | }
126 |
127 | // MustWriteJSON is a helper that writes json data to the connection.
128 | func (h *Handler) MustWriteJSON(data any) {
129 | _, err := h.WriteJSON(data)
130 | if err != nil {
131 | panic(err)
132 | }
133 | }
134 |
135 | // WriteHeader sets the HTTP response header with the provided status code.
136 | func (h *Handler) WriteHeader(code int) {
137 | h.w.WriteHeader(code)
138 | }
139 |
140 | // StopPolling sets the response status to 286, which will stop htmx from polling
141 | func (h *Handler) StopPolling() {
142 | h.WriteHeader(StatusStopPolling)
143 | }
144 |
145 | // Header returns the header map that will be sent by WriteHeader
146 | func (h *Handler) Header() http.Header {
147 | return h.w.Header()
148 | }
149 |
150 | type LocationInput struct {
151 | Source string `json:"source"` // source - the source element of the request
152 | Event string `json:"event"` //event - an event that "triggered" the request
153 | Handler string `json:"handler"` //handler - a callback that will handle the response HTML
154 | Target string `json:"target"` //target - the target to swap the response into
155 | Swap string `json:"swap"` //swap - how the response will be swapped in relative to the target
156 | Values map[string]interface{} `json:"values"` //values - values to submit with the request
157 | Header map[string]interface{} `json:"headers"` //headers - headers to submit with the request
158 |
159 | }
160 |
161 | // Location can be used to trigger a client side redirection without reloading the whole page
162 | // https://htmx.org/headers/hx-location/
163 | func (h *Handler) Location(li *LocationInput) error {
164 | payload, err := json.Marshal(li)
165 | if err != nil {
166 | return err
167 | }
168 |
169 | h.response.Set(HXLocation, string(payload))
170 | return nil
171 | }
172 |
173 | // PushURL pushes a new url into the history stack.
174 | // https://htmx.org/headers/hx-push-url/
175 | func (h *Handler) PushURL(val string) {
176 | h.response.Set(HXPushUrl, val)
177 | }
178 |
179 | // Redirect can be used to do a client-side redirect to a new location
180 | func (h *Handler) Redirect(val string) {
181 | h.response.Set(HXRedirect, val)
182 | }
183 |
184 | // Refresh if set to true the client side will do a full refresh of the page
185 | func (h *Handler) Refresh(val bool) {
186 | h.response.Set(HXRefresh, HxBoolToStr(val))
187 | }
188 |
189 | // ReplaceURL allows you to replace the current URL in the browser location history.
190 | // https://htmx.org/headers/hx-replace-url/
191 | func (h *Handler) ReplaceURL(val string) {
192 | h.response.Set(HXReplaceUrl, val)
193 | }
194 |
195 | // ReSwap allows you to specify how the response will be swapped. See hx-swap for possible values
196 | // https://htmx.org/attributes/hx-swap/
197 | func (h *Handler) ReSwap(val string) {
198 | h.response.Set(HXReswap, val)
199 | }
200 |
201 | // ReSwapWithObject allows you to specify how the response will be swapped. See hx-swap for possible values
202 | // https://htmx.org/attributes/hx-swap/
203 | func (h *Handler) ReSwapWithObject(s *Swap) {
204 | h.ReSwap(s.String())
205 | }
206 |
207 | // ReTarget a CSS selector that updates the target of the content update to a different element on the page
208 | func (h *Handler) ReTarget(val string) {
209 | h.response.Set(HXRetarget, val)
210 | }
211 |
212 | // ReSelect a CSS selector that allows you to choose which part of the response is used to be swapped in. Overrides an existing hx-select on the triggering element
213 | func (h *Handler) ReSelect(val string) {
214 | h.response.Set(HXReselect, val)
215 | }
216 |
217 | // Trigger triggers events as soon as the response is received.
218 | // https://htmx.org/headers/hx-trigger/
219 | func (h *Handler) Trigger(val string) {
220 | h.response.Set(HXTrigger, val)
221 | }
222 |
223 | // TriggerWithObject triggers events as soon as the response is received.
224 | // https://htmx.org/headers/hx-trigger/
225 | func (h *Handler) TriggerWithObject(t *Trigger) {
226 | h.Trigger(t.String())
227 | }
228 |
229 | // TriggerAfterSettle trigger events after the settling step.
230 | // https://htmx.org/headers/hx-trigger/
231 | func (h *Handler) TriggerAfterSettle(val string) {
232 | h.response.Set(HXTriggerAfterSettle, val)
233 | }
234 |
235 | // TriggerAfterSettleWithObject trigger events after the settling step.
236 | // https://htmx.org/headers/hx-trigger/
237 | func (h *Handler) TriggerAfterSettleWithObject(t *Trigger) {
238 | h.TriggerAfterSettle(t.String())
239 | }
240 |
241 | // TriggerAfterSwap trigger events after the swap step.
242 | // https://htmx.org/headers/hx-trigger/
243 | func (h *Handler) TriggerAfterSwap(val string) {
244 | h.response.Set(HXTriggerAfterSwap, val)
245 | }
246 |
247 | // TriggerAfterSwapWithObject trigger events after the swap step.
248 | // https://htmx.org/headers/hx-trigger/
249 | func (h *Handler) TriggerAfterSwapWithObject(t *Trigger) {
250 | h.TriggerAfterSwap(t.String())
251 | }
252 |
253 | // Request returns the HxHeaders from the request
254 | func (h *Handler) Request() HxRequestHeader {
255 | return h.request
256 | }
257 |
258 | // ResponseHeader returns the value of the response header
259 | func (h *Handler) ResponseHeader(header HxResponseKey) string {
260 | return h.response.Get(header)
261 | }
262 |
263 | // Render renders the given renderer with the given context and writes the output to the response writer
264 | func (h *Handler) Render(ctx context.Context, r RenderableComponent) (int, error) {
265 | r.SetURL(h.r.URL)
266 |
267 | output, err := r.Render(ctx)
268 | if err != nil {
269 | return 0, err
270 | }
271 |
272 | // If it's a partial render, return the output directly
273 | if h.RenderPartial() {
274 | return h.WriteHTML(output)
275 | }
276 |
277 | // Recursively wrap the output if the component is wrapped
278 | output, err = h.wrapOutput(ctx, r, output)
279 | if err != nil {
280 | return 0, err
281 | }
282 |
283 | // Write the final output
284 | return h.WriteHTML(output)
285 | }
286 |
287 | // wrapOutput recursively wraps the output in its parent components
288 | func (h *Handler) wrapOutput(ctx context.Context, r RenderableComponent, output template.HTML) (template.HTML, error) {
289 | if !r.isWrapped() {
290 | // Base case: no more wrapping
291 | return output, nil
292 | }
293 |
294 | parent := r.wrapper()
295 | parent.SetURL(h.r.URL)
296 | parent.injectData(r.data())
297 | parent.addPartial(r.target(), output)
298 |
299 | // Render the parent component
300 | parentOutput, err := parent.Render(ctx)
301 | if err != nil {
302 | return "", err
303 | }
304 |
305 | // Recursively wrap the parent output if the parent is also wrapped
306 | return h.wrapOutput(ctx, parent, parentOutput)
307 | }
308 |
--------------------------------------------------------------------------------
/COMPONENTS.md:
--------------------------------------------------------------------------------
1 | # go-htmx Component Documentation
2 |
3 | The `go-htmx` package provides a flexible and efficient way to render partial or full HTML pages in Go applications. It leverages Go's `html/template` package to render templates with dynamic data, supporting features like partial rendering, template wrapping, and data injection.
4 |
5 | ---
6 |
7 | ## Table of Contents
8 |
9 | 1. [Introduction](#introduction)
10 | 2. [Getting Started](#getting-started)
11 | 3. [Creating Components](#creating-components)
12 | 4. [Rendering Components](#rendering-components)
13 | 5. [Wrapping Components](#wrapping-components)
14 | 6. [Adding Partials](#adding-partials)
15 | 7. [Attaching Templates](#attaching-templates)
16 | 8. [Working with Data](#working-with-data)
17 | 9. [Template Functions](#template-functions)
18 | 10. [Reusing Components](#reusing-components)
19 | 11. [Caveats and Warnings](#caveats-and-warnings)
20 | 12. [Example Usage](#example-usage)
21 | 13. [Configuration Options](#configuration-options)
22 | 14. [Conclusion](#conclusion)
23 | 15. [Additional Notes](#additional-notes)
24 | 16. [Internal Details](#internal-details)
25 | 17. [Caveats and Warnings (Detailed)](#caveats-and-warnings-detailed)
26 | 18. [Feedback and Contributions](#feedback-and-contributions)
27 |
28 | ---
29 |
30 | ## Introduction
31 |
32 | The `go-htmx` package simplifies the process of rendering HTML templates in Go by introducing the concept of components. Components encapsulate templates, data, and rendering logic, making it easier to build complex web pages with reusable parts.
33 |
34 | ---
35 |
36 | ## Getting Started
37 |
38 | To use the `go-htmx` package, you need to import it into your Go project:
39 |
40 | ```go
41 | import "github.com/donseba/go-htmx"
42 | ```
43 |
44 | Ensure you have the package installed:
45 |
46 | ```bash
47 | go get github.com/donseba/go-htmx
48 | ```
49 |
50 | ---
51 |
52 | ## Creating Components
53 | A component represents a renderable unit, typically associated with one or more template files. You can create a new component using the `NewComponent` function:
54 |
55 | ```go
56 | component := htmx.NewComponent("templates/base.html")
57 | ```
58 |
59 | You can specify multiple template files if needed:
60 |
61 | ```go
62 | component := htmx.NewComponent("templates/header.html", "templates/body.html", "templates/footer.html")
63 | ```
64 |
65 | ---
66 |
67 | ## Rendering Components
68 | To render a component, you call its `Render` method, passing in a `context.Context`:
69 |
70 | ```go
71 | htmlContent, err := component.Render(ctx)
72 | if err != nil {
73 | // Handle error
74 | }
75 | ```
76 |
77 | The `Render` method processes the templates and returns the rendered HTML content as a `template.HTML` type.
78 |
79 | ---
80 |
81 | ## Wrapping Components
82 | Components can be wrapped inside other components, allowing you to nest templates and create complex layouts. Use the Wrap method to wrap a component:
83 |
84 | ```go
85 | wrapperComponent := htmx.NewComponent("templates/wrapper.html")
86 | component.Wrap(wrapperComponent, "content")
87 | ```
88 |
89 | In the wrapper template, you can define a placeholder (e.g., `{{ .Partials.content }})` where the wrapped component's content will be inserted.
90 |
91 | ---
92 |
93 | ## Adding Partials
94 | Partials are sub-components that can be included within a component. Use the With method to add a partial:
95 | ```go
96 | partialComponent := htmx.NewComponent("templates/partial.html")
97 | component.With(partialComponent, "sidebar")
98 | ```
99 |
100 | In your main component's template, you can reference the partial using `{{ .Partials.sidebar }}`.
101 |
102 | ---
103 |
104 | ## Attaching Templates
105 | If you have additional templates that you want to include without rendering them as partials, you can use the Attach method:
106 | ```go
107 | component.Attach("templates/extra.html")
108 | ```
109 | This method appends the template to the component's template list.
110 |
111 | ## Working with Data
112 | You can pass dynamic data to your templates using the SetData and AddData methods.
113 |
114 | ### Setting Data
115 |
116 | Set multiple data values at once:
117 | ```go
118 | data := map[string]interface{}{
119 | "Title": "Welcome Page",
120 | "Message": "Hello, World!",
121 | }
122 | component.SetData(data)
123 | ```
124 |
125 | ### Adding Data
126 | Add individual data values:
127 | ```go
128 | component.AddData("Title", "Welcome Page")
129 | component.AddData("Message", "Hello, World!")
130 | ```
131 |
132 | ### Global Data
133 | Set data that is accessible to all components and partials using `SetGlobalData` and `AddGlobalData`:
134 | ```go
135 | component.SetGlobalData(map[string]interface{}{
136 | "AppName": "My Go App",
137 | })
138 |
139 | component.AddGlobalData("Version", "1.0.0")
140 | ```
141 |
142 | ---
143 |
144 | ## Template Functions
145 | You can enhance your templates with custom functions using `AddTemplateFunction` and `AddTemplateFunctions`.
146 |
147 | ### Adding a Single Function
148 | ```go
149 | component.AddTemplateFunction("formatDate", func(t time.Time) string {
150 | return t.Format("Jan 2, 2006")
151 | })
152 | ```
153 |
154 | Use `{{ formatDate .Data.Timestamp }}` in your template.
155 |
156 | ### Adding Multiple Functions
157 | ```go
158 | funcMap := template.FuncMap{
159 | "toUpper": strings.ToUpper,
160 | "safeHTML": func(s string) template.HTML {
161 | return template.HTML(s)
162 | },
163 | }
164 |
165 | component.AddTemplateFunctions(funcMap)
166 | ```
167 |
168 | ---
169 |
170 | ## Reusing Components
171 | If you need to reuse a Component instance, be aware that internal state (like data and partials) may persist between renders. To reset the component's state, use the Reset method:
172 | ```go
173 | component.Reset()
174 | ```
175 |
176 | This method clears the component's data, global data, partials, and URL, allowing you to reuse it without residual state.
177 |
178 | ---
179 |
180 | ## Caveats and Warnings
181 |
182 | ### Thread Safety
183 |
184 | Warning: Component instances are not thread-safe and should not be shared across goroutines. If you need to render components concurrently, create separate instances for each goroutine.
185 | Reusing Components
186 |
187 | - **State Persistence**: When reusing a component, internal state such as data and partials may persist. Always call Reset() before reusing a component to avoid unintended data leakage.
188 | - **Resetting Components**: The Reset method clears data, global data, partials, and the URL. It does not reset templates or functions.
189 |
190 | ### URL Propagation
191 |
192 | - **Setting the URL**: When you set the URL on a component using SetURL, it is recursively propagated to all partials, including nested ones.
193 | - **Adding Partials After Setting URL**: If you add partials after setting the URL, you may need to call SetURL again to ensure the new partials receive the URL.
194 |
195 | ### Data Overwriting in injectData
196 |
197 | - **Non-Overwriting Behavior**: The injectData method does not overwrite existing keys in templateData. If a key already exists, it will not be replaced.
198 | - **Recommendation**: Be mindful of this behavior when injecting data to avoid unexpected results.
199 |
200 | ---
201 |
202 | ## Example Usage
203 |
204 | Here's a complete example demonstrating how to use the go-htmx package:
205 |
206 | ```go
207 | package main
208 |
209 | import (
210 | "context"
211 | "fmt"
212 | "github.com/donseba/go-htmx"
213 | "net/http"
214 | )
215 |
216 | type (
217 | App struct {
218 | htmx *htmx.HTMX
219 | }
220 | )
221 |
222 | func (a *App) handler(w http.ResponseWriter, r *http.Request) {
223 | // Create main component
224 | mainComponent := htmx.NewComponent("templates/main.html")
225 |
226 | // Set data
227 | mainComponent.SetData(map[string]interface{}{
228 | "Title": "Home Page",
229 | })
230 |
231 | // Add a partial
232 | headerComponent := htmx.NewComponent("templates/header.html")
233 | mainComponent.With(headerComponent, "header")
234 |
235 | // Set URL (propagates to all partials)
236 | mainComponent.SetURL(r.URL)
237 |
238 | h := a.htmx.NewHandler(w, r)
239 |
240 | _, err = h.Render(r.Context(), mainComponent)
241 | if err != nil {
242 | fmt.Printf("error rendering page: %v", err.Error())
243 | }
244 | }
245 |
246 | func main() {
247 | app := &App{
248 | htmx: htmx.New(),
249 | }
250 |
251 | http.HandleFunc("/", app.handler)
252 | http.ListenAndServe(":8080", nil)
253 | }
254 | ```
255 |
256 | ---
257 |
258 | ## Configuration Options
259 |
260 | ### Template Functions
261 | The package provides a default function map `DefaultTemplateFuncs` that you can populate with common functions.
262 | ```go
263 | htmx.DefaultTemplateFuncs = template.FuncMap{
264 | "toUpper": strings.ToUpper,
265 | }
266 | ```
267 |
268 | ### Template Caching
269 | Templates are cached by default for performance. You can control this behavior using the `UseTemplateCache` variable:
270 | ```go
271 | htmx.UseTemplateCache = false // Disable template caching
272 | ```
273 |
274 | ---
275 |
276 | ## Conclusion
277 | The Component addition offers a powerful way to manage and render templates in Go applications. By structuring your templates into components and partials, and by leveraging data injection and custom template functions, you can build dynamic and maintainable web pages.
278 |
279 |
280 | ---
281 |
282 | ## Additional Notes
283 |
284 | - Context in Templates: The context passed to Render is available in templates as `{{ .Ctx }}`.
285 | - Data Access in Templates
286 | - **Accessing Data**: Use `{{ .Data.Key }}` to access data values in templates.
287 | - **Global Data**: Global data is accessible as `{{ .Global.Key }}` in templates.
288 | - **Partials**: Partials are available as `{{ .Partials.Key }}` in templates.
289 | - **URL**: The URL is accessible as `{{ .URL }}` in templates.
290 |
291 | ---
292 |
293 | ## Internal Details (For Advanced Users)
294 |
295 | ### Data Structures
296 | - **Component**: Implements the `RenderableComponent` interface and holds all the necessary information to render templates, including data, partials, and template functions.
297 |
298 | ### Rendering Process
299 | 1. **Partial Rendering**: Before rendering the main template, any partial components added via `With` are rendered, and their output is stored.
300 | 2. **Template Parsing**: Templates are parsed and cached (if caching is enabled).
301 | 3. **Data Preparation**: A data structure containing context, data, global data, partials, and URL is prepared.
302 | 4. **Execution**: The template is executed with the prepared data.
303 |
304 | ### Important Methods
305 | - `Render(ctx context.Context) (template.HTML, error)`: Renders the component.
306 | - `Wrap(renderer RenderableComponent, target string) RenderableComponent`: Wraps the component with another renderer.
307 | - `With(r RenderableComponent, target string) RenderableComponent`: Adds a partial component.
308 | - `SetData(input map[string]interface{}) RenderableComponent`: Sets the template data.
309 | - `AddTemplateFunction(name string, function interface{}) RenderableComponent`: Adds a custom template function.
310 | - `Reset() *Component`: Resets the component's state.
311 |
312 | ---
313 |
314 | ## Caveats and Warnings (Detailed)
315 |
316 | ### Thread Safety
317 | Important: `Component` instances are not thread-safe. Do not share a single Component instance across multiple goroutines. Each goroutine should create its own instance of Component to avoid race conditions and undefined behavior.
318 | There is currently no real usage so far for using Components across multiple goroutines, if the need arises, we can discuss and implement a thread-safe version of the Component.
319 |
320 | ### Reusing Components
321 | - **State Persistence**: The `Component` retains its state between renders. If you reuse a Component, data, partials, and other settings from previous renders may persist.
322 | - **Using Reset**: To reuse a `Component` safely, call `Reset()` to clear its state before setting new data or partials.
323 |
324 | ### Data Injection Behavior
325 | - **Non-Overwriting in `injectData`**: The `injectData` method does not overwrite existing keys in templateData. This means that if a key exists in both the component's data and the injected data, the component's data takes precedence.
326 | - **Best Practice**: Be explicit with your data keys and manage them carefully to prevent unexpected behavior.
327 |
328 | ### URL Handling
329 | - **Propagation to Partials**: When you set the URL on a component using `SetURL`, it automatically propagates to all its partials, including nested ones.
330 | - **Adding Partials After URL Is Set**: If you add partials after setting the URL, you need to call `SetURL` again to ensure the new partials receive the URL.
331 |
332 | ### Template Caching
333 | - **Function Map Consideration**: The template caching mechanism accounts for custom function maps. Templates with different function maps are cached separately.
334 | - **Disabling Cache**: You can disable template caching during development or debugging by setting `UseTemplateCache` to `false`.
335 |
336 | ---
337 |
338 | ## Feedback and Contributions
339 | We welcome feedback, suggestions, and contributions to the `go-htmx` package. If you have ideas for improvements, new features, or bug fixes, please open an issue or submit a pull request on the GitHub repository.
340 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # go-htmx
2 | **Seamless HTMX integration in golang applications.**
3 |
4 | [](https://pkg.go.dev/github.com/donseba/go-htmx?tab=doc)
5 | [](https://github.com/donseba/go-htmx)
6 | [](https://github.com/donseba/go-htmx)
7 | [](./LICENSE)
8 | [](https://github.com/donseba/go-htmx/stargazers)
9 | [](https://goreportcard.com/report/github.com/donseba/go-htmx)
10 |
11 | ## Description
12 |
13 | This repository contains the htmx Go package, designed to enhance server-side handling of HTML generated with the [HTMX library](https://htmx.org/).
14 | It provides a set of tools to easily manage swap behaviors, trigger configurations, and other HTMX-related functionalities in a Go server environment.
15 |
16 | ## Disclaimer
17 | This package is built around the specific need to be able to work with HTMX in a Go environment.
18 | All functionality found in this repository has a certain use case in various projects that I have worked on.
19 |
20 | - Design decisions are documented in the [DESIGN_DECISIONS.md](https://github.com/donseba/go-htmx/blob/main/DESIGN_DECISIONS.md) file.
21 | - Locality of Behavior is documented in the [LOB.md](https://github.com/donseba/go-htmx/blob/main/LOB.md) file.
22 |
23 | ## Features
24 |
25 | - **Component Rendering**: Render (partial) components in response to HTMX requests, enhancing user experience and performance.
26 | - **Swap Configuration**: Configure swap behaviors for HTMX responses, including style, timing, and scrolling.
27 | - **Trigger Management**: Define and manage triggers for HTMX events, supporting both simple and detailed triggers.
28 | - **Middleware Support**: Integrate HTMX seamlessly with Go middleware for easy HTMX header configuration.
29 | - **io.Writer Support**: The HTMX handler implements the io.Writer interface for easy integration with existing Go code.
30 |
31 | ---
32 |
33 | ## Getting Started
34 |
35 | ### Installation
36 |
37 | To install the htmx package, use the following command:
38 |
39 | ```sh
40 | go get -u github.com/donseba/go-htmx
41 | ```
42 |
43 | ### Usage
44 |
45 | initialize the htmx service like so :
46 | ```go
47 | package main
48 |
49 | import (
50 | "log"
51 | "net/http"
52 |
53 | "github.com/donseba/go-htmx"
54 | )
55 |
56 | type App struct {
57 | htmx *htmx.HTMX
58 | }
59 |
60 | func main() {
61 | // new app with htmx instance
62 | app := &App{
63 | htmx: htmx.New(),
64 | }
65 |
66 | mux := http.NewServeMux()
67 | // wrap the htmx example middleware around the http handler
68 | mux.HandleFunc("/", app.Home)
69 |
70 | err := http.ListenAndServe(":3000", mux)
71 | log.Fatal(err)
72 | }
73 |
74 | func (a *App) Home(w http.ResponseWriter, r *http.Request) {
75 | // initiate a new htmx handler
76 | h := a.htmx.NewHandler(w, r)
77 |
78 | // check if the request is a htmx request
79 | if h.IsHxRequest() {
80 | // do something
81 | }
82 |
83 | // check if the request is boosted
84 | if h.IsHxBoosted() {
85 | // do something
86 | }
87 |
88 | // check if the request is a history restore request
89 | if h.IsHxHistoryRestoreRequest() {
90 | // do something
91 | }
92 |
93 | // check if the request is a prompt request
94 | if h.RenderPartial() {
95 | // do something
96 | }
97 |
98 | // set the headers for the response, see docs for more options
99 | h.PushURL("http://push.url")
100 | h.ReTarget("#ReTarged")
101 |
102 | // write the output like you normally do.
103 | // check the inspector tool in the browser to see that the headers are set.
104 | _, _ = h.Write([]byte("OK"))
105 | }
106 | ```
107 |
108 | ### HTMX Request Checks
109 |
110 | The htmx package provides several functions to determine the nature of HTMX requests in your Go application. These checks allow you to tailor the server's response based on specific HTMX-related conditions.
111 |
112 | #### IsHxRequest
113 |
114 | This function checks if the incoming HTTP request is made by HTMX.
115 |
116 | ```go
117 | func (h *Handler) IsHxRequest() bool
118 | ```
119 | - **Usage**: Use this check to identify requests initiated by HTMX and differentiate them from standard HTTP requests.
120 | - **Example**: Applying special handling or returning partial HTML snippets in response to an HTMX request.
121 |
122 | #### IsHxBoosted
123 |
124 | Determines if the HTMX request is boosted, which typically indicates an enhancement of the user experience with HTMX's AJAX capabilities.
125 |
126 | ```go
127 | func (h *Handler) IsHxBoosted() bool
128 | ```
129 | - **Usage**: Useful in scenarios where you want to provide an enriched or different response for boosted requests.
130 | - **Example**: Loading additional data or scripts that are specifically meant for AJAX-enhanced browsing.
131 |
132 | #### IsHxHistoryRestoreRequest
133 |
134 | Checks if the HTMX request is a history restore request. This type of request occurs when HTMX is restoring content from the browser's history.
135 |
136 | ```go
137 | func (h *Handler) IsHxHistoryRestoreRequest() bool
138 | ```
139 | - **Usage**: Helps in handling scenarios where users navigate using browser history, and the application needs to restore previous states or content.
140 | - **Example**: Resetting certain states or re-fetching data that was previously displayed.
141 |
142 | #### RenderPartial
143 |
144 | This function returns true for HTMX requests that are either standard or boosted, as long as they are not history restore requests. It is a combined check used to determine if a partial render is appropriate.
145 |
146 | ```go
147 | func (h *Handler) RenderPartial() bool
148 | ```
149 | - **Usage**: Ideal for deciding when to render partial HTML content, which is a common pattern in applications using HTMX.
150 | - **Example**: Returning only the necessary HTML fragments to update a part of the webpage, instead of rendering the entire page.
151 |
152 | ### Swapping
153 | Swapping is a way to replace the content of a dom element with the content of the response.
154 | This is done by setting the `HX-Swap` header to the id of the dom element you want to swap.
155 |
156 | ```go
157 | func (c *Controller) Route(w http.ResponseWriter, r *http.Request) {
158 | // initiate a new htmx handler
159 | h := a.htmx.NewHandler(w, r)
160 |
161 | // Example usage of Swap
162 | swap := htmx.NewSwap().Swap(time.Second * 2).ScrollBottom()
163 |
164 | h.ReSwapWithObject(swap)
165 |
166 | _, _ = h.Write([]byte("your content"))
167 | }
168 | ```
169 |
170 | ### Trigger Events
171 | Trigger events are a way to trigger events on the dom element.
172 | This is done by setting the `HX-Trigger` header to the event you want to trigger.
173 |
174 | ```go
175 | func (c *Controller) Route(w http.ResponseWriter, r *http.Request) {
176 | // initiate a new htmx handler
177 | h := a.htmx.NewHandler(w, r)
178 |
179 | // Example usage of Swap
180 | trigger := htmx.NewTrigger().AddEvent("event1").AddEventDetailed("event2", "Hello, World!")
181 |
182 | h.TriggerWithObject(trigger)
183 | // or
184 | h.TriggerAfterSettleWithObject(trigger)
185 | // or
186 | h.TriggerAfterSwapWithObject(trigger)
187 |
188 | _, _ = h.Write([]byte("your content"))
189 | }
190 | ```
191 |
192 | ---
193 |
194 | ## utility methods
195 |
196 | ### Notification handling
197 | Comprehensive support for triggering various types of notifications within your Go applications, enhancing user interaction and feedback. The package provides a set of functions to easily manage and trigger different notification types such as success, info, warning, error, and custom notifications.
198 | Available Notification Types
199 |
200 | - **Success**: Use for positive confirmation messages.
201 | - **Info**: Ideal for informational messages.
202 | - **Warning**: Suitable for cautionary messages.
203 | - **Error**: Use for error or failure messages.
204 | - **Custom**: Allows for defining your own notification types.
205 |
206 | ### Usage
207 |
208 | Triggering notifications is straightforward. Here are some examples demonstrating how to use each function:
209 |
210 | ```go
211 | func (h *Handler) MyHandlerFunc(w http.ResponseWriter, r *http.Request) {
212 | // Trigger a success notification
213 | h.TriggerSuccess("Operation completed successfully")
214 |
215 | // Trigger an info notification
216 | h.TriggerInfo("This is an informational message")
217 |
218 | // Trigger a warning notification
219 | h.TriggerWarning("Warning: Please check your input")
220 |
221 | // Trigger an error notification
222 | h.TriggerError("Error: Unable to process your request")
223 |
224 | // Trigger a custom notification
225 | h.TriggerCustom("customType", "This is a custom notification", nil)
226 | }
227 | ```
228 |
229 | ### Notification Levels
230 |
231 | The htmx package provides built-in support for four primary notification levels, each representing a different type of message:
232 |
233 | - `success`: Indicates successful completion of an operation.
234 | - `info`: Conveys informational messages.
235 | - `warning`: Alerts about potential issues or cautionary information.
236 | - `error`: Signals an error or problem that occurred.
237 |
238 | Each notification type is designed to communicate specific kinds of messages clearly and effectively in your application's user interface.
239 | ### Triggering Custom Notifications
240 |
241 | In addition to these standard notification levels, the htmx package also allows for custom notifications using the TriggerCustom method. This method provides the flexibility to define a custom level and message, catering to unique notification requirements.
242 |
243 | ```go
244 | func (h *Handler) MyHandlerFunc(w http.ResponseWriter, r *http.Request) {
245 | // Trigger standard notifications
246 | h.TriggerSuccess("Operation successful")
247 | h.TriggerInfo("This is for your information")
248 | h.TriggerWarning("Please be cautious")
249 | h.TriggerError("An error has occurred")
250 |
251 | // Trigger a custom notification
252 | h.TriggerCustom("customLevel", "This is a custom notification")
253 | }
254 | ```
255 | The TriggerCustom method enables you to specify a custom level (e.g., "customLevel") and an accompanying message. This method is particularly useful when you need to go beyond the predefined notification types and implement a notification system that aligns closely with your application's specific context or branding.
256 |
257 | ### Advanced Usage with Custom Variables
258 |
259 | You can also pass additional data with your notifications. Here's an example:
260 |
261 | ```go
262 | func (h *Handler) MyHandlerFunc(w http.ResponseWriter, r *http.Request) {
263 | customData := map[string]string{"key1": "value1", "key2": "value2"}
264 | h.TriggerInfo("User logged in", customData)
265 | }
266 | ```
267 |
268 | ### the HTMX part
269 |
270 | please refer to the [htmx documentation](https://htmx.org/headers/hx-trigger/) regarding event triggering. and the example [confirmation UI](https://htmx.org/examples/confirm/)
271 |
272 | `HX-Trigger: {"showMessage":{"level" : "info", "message" : "Here Is A Message"}}`
273 |
274 | And handle this event like so:
275 |
276 | ```js
277 | document.body.addEventListener("showMessage", function(evt){
278 | if(evt.detail.level === "info"){
279 | alert(evt.detail.message);
280 | }
281 | })
282 | ```
283 | Each property of the JSON object on the right hand side will be copied onto the details object for the event.
284 |
285 | ### Customizing Notification Event Names
286 |
287 | In addition to the standard notification types, the htmx package allows you to customize the event name used for triggering notifications. This is done by modifying the htmx.DefaultNotificationKey. Changing this key will affect the event name in the HTMX trigger, allowing you to tailor it to specific needs or naming conventions of your application.
288 | Setting a Custom Notification Key
289 |
290 | Before triggering notifications, you can set a custom event name as follows:
291 |
292 | ```go
293 | htmx.DefaultNotificationKey = "myCustomEventName"
294 | ```
295 |
296 | ---
297 |
298 | ## Component Rendering
299 |
300 | The components documentation can be found in the [COMPONENTS.md](https://github.com/donseba/go-htmx/blob/main/COMPONENTS.md) file.
301 |
302 | ---
303 |
304 | ## Middleware
305 | The htmx package is designed for versatile integration into Go applications, providing support both with and without the use of middleware. Below, we showcase two examples demonstrating the package's usage in scenarios involving middleware.
306 |
307 | ### standard mux middleware example:
308 |
309 | ```go
310 | func MiddleWare(next http.Handler) http.Handler {
311 | fn := func(w http.ResponseWriter, r *http.Request) {
312 | ctx := r.Context()
313 |
314 | hxh := htmx.HxRequestHeaderFromRequest(c.Request())
315 |
316 | ctx = context.WithValue(ctx, htmx.ContextRequestHeader, hxh)
317 |
318 | next.ServeHTTP(w, r.WithContext(ctx))
319 | }
320 | return http.HandlerFunc(fn)
321 | }
322 | ```
323 |
324 | **NOTE** : The `MiddleWare` function is deprecated but will remain as a reference for users who prefer to use it.
325 | It would be best to create your own middleware function that fits your application's requirements.
326 |
327 | ### echo middleware example:
328 |
329 | ```go
330 | func MiddleWare(next echo.HandlerFunc) echo.HandlerFunc {
331 | return func(c echo.Context) error {
332 | ctx := c.Request().Context()
333 |
334 | hxh := htmx.HxRequestHeaderFromRequest(c.Request())
335 |
336 | ctx = context.WithValue(ctx, htmx.ContextRequestHeader, hxh)
337 |
338 | c.SetRequest(c.Request().WithContext(ctx))
339 |
340 | return next(c)
341 | }
342 | }
343 | ```
344 |
345 | ---
346 |
347 | ## Custom logger
348 |
349 | In case you want to use a custom logger, like zap, you can inject them into the slog package like so:
350 |
351 | ```go
352 | import (
353 | "go.uber.org/zap"
354 | "go.uber.org/zap/exp/zapslog"
355 | )
356 |
357 | func main() {
358 | // create a new htmx instance with the logger
359 | app := &App{
360 | htmx: htmx.New(),
361 | }
362 |
363 | zapLogger := zap.Must(zap.NewProduction())
364 | defer zapLogger.Sync()
365 |
366 | logger := slog.New(zapslog.NewHandler(zapLogger.Core(), nil))
367 |
368 | app.htmx.SetLog(logger)
369 | }
370 | ```
371 |
372 | ---
373 |
374 | ## Usage in other frameworks
375 | The htmx package is designed to be versatile and can be used in various Go web frameworks.
376 | Below are examples of how to use the package in two popular Go web frameworks: Echo and Gin.
377 |
378 | ### echo
379 |
380 | ```go
381 | func (c *controller) Hello(c echo.Context) error {
382 | // initiate a new htmx handler
383 | h := c.app.htmx.NewHandler(c.Response(), c.Request())
384 |
385 | // Example usage of Swap
386 | swap := htmx.NewSwap().Swap(time.Second * 2).ScrollBottom()
387 |
388 | h.ReSwapWithObject(swap)
389 |
390 | _, _ = h.Write([]byte("your content"))
391 | }
392 | ```
393 |
394 | ### gin
395 |
396 | ```go
397 | func (c *controller) Hello(c *gin.Context) {
398 | // initiate a new htmx handler
399 | h := c.app.htmx.NewHandler(c.Writer, c.Request)
400 |
401 | // Example usage of Swap
402 | swap := htmx.NewSwap().Swap(time.Second * 2).ScrollBottom()
403 |
404 | h.ReSwapWithObject(swap)
405 |
406 | _, _ = h.Write([]byte("your content"))
407 | }
408 | ```
409 |
410 | ---
411 |
412 | ## Server Sent Events (SSE)
413 |
414 | The htmx package provides support for Server-Sent Events (SSE) in Go applications. This feature allows you to send real-time updates from the server to the client, enabling live updates and notifications in your web application.
415 |
416 | You can read about this feature in the [htmx documentation](https://htmx.org/extensions/server-sent-events/) and the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events).
417 |
418 | ### Usage
419 |
420 | Create an endpoint in your Go application to handle SSE requests. (see the example for a better understanding)
421 | ```go
422 | func (a *App) SSE(w http.ResponseWriter, r *http.Request) {
423 | cl := &client{
424 | id: uuid.New().String()
425 | ch: make(chan *htmx.SSEMessage),
426 | }
427 |
428 | sseManager.Handle(w, cl)
429 | }
430 | ```
431 |
432 | To send a message to the client, you can use the `Send` method on the `SSEManager` object.
433 |
434 | ```go
435 | go func() {
436 | for {
437 | // Send a message every seconds
438 | time.Sleep(1 * time.Second)
439 |
440 | msg := sse.
441 | NewMessage(fmt.Sprintf("The current time is: %v", time.Now().Format(time.RFC850))).
442 | WithEvent("Time")
443 |
444 | sseManager.Send()
445 | }
446 | }()
447 | ```
448 |
449 | ### HTMX helper methods
450 |
451 | There are two helper methods to simplify the usage of SSE in your HTMX application.
452 | The Manager is created in the background and is not exposed to the user.
453 | You can change the default worker pool size by setting the `htmx.DefaultSSEWorkerPoolSize` variable.
454 |
455 | ```go
456 |
457 | // SSEHandler handles the server-sent events. this is a shortcut and is not the preferred way to handle sse.
458 | func (h *HTMX) SSEHandler(w http.ResponseWriter, cl sse.Client)
459 |
460 | // SSESend sends a message to all connected clients.
461 | func (h *HTMX) SSESend(message sse.Envelope)
462 |
463 | ```
464 | ---
465 |
466 | ## Contributing
467 |
468 | Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
469 |
470 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".
471 |
472 | **Remember to give the project a star! Thanks again!**
473 |
474 | 1. Fork this repo
475 | 2. Create a new branch with `main` as the base branch
476 | 3. Add your changes
477 | 4. Raise a Pull Request
478 |
479 | ---
480 |
481 | ## License
482 |
483 | Distributed under the MIT License. See `LICENSE` for more information.
--------------------------------------------------------------------------------