├── 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 | 9 | {{end}} 10 | 11 | {{end}} 12 | 13 |
7 | {{ $cell }} 8 |
-------------------------------------------------------------------------------- /.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 |
3 | {{ range $target := .Data.MenuItems }} 4 | {{ $target.Name }} 9 | {{ end }} 10 |
11 | -------------------------------------------------------------------------------- /examples/sse/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Server Sent Time Example 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |
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 | 9 | 10 | {{else}} 11 | 12 | 15 | 16 | {{end}} 17 | 18 | {{range $i, $row := .Data.game.Board }} 19 | 20 | {{range $j, $cell := $row}} 21 | 26 | {{end}} 27 | 28 | {{end}} 29 |
7 | {{if eq . "X"}}Player X wins!{{else if eq . "O"}}Player O wins!{{else}}It's a draw!{{end}} 8 |
13 | {{.Data.game.Turn }}'s turn 14 |
22 | 23 | {{$cell}} 24 | 25 |
-------------------------------------------------------------------------------- /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 | 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 := `` + pokemon.Name + `` 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 | [![GoDoc](https://pkg.go.dev/badge/github.com/donseba/go-htmx?status.svg)](https://pkg.go.dev/github.com/donseba/go-htmx?tab=doc) 5 | [![GoMod](https://img.shields.io/github/go-mod/go-version/donseba/go-htmx)](https://github.com/donseba/go-htmx) 6 | [![Size](https://img.shields.io/github/languages/code-size/donseba/go-htmx)](https://github.com/donseba/go-htmx) 7 | [![License](https://img.shields.io/github/license/donseba/go-htmx)](./LICENSE) 8 | [![Stars](https://img.shields.io/github/stars/donseba/go-htmx)](https://github.com/donseba/go-htmx/stargazers) 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/donseba/go-htmx)](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. --------------------------------------------------------------------------------