├── images ├── eatme.png ├── other-1.png ├── drinkme-1.png ├── drinkme-2.png ├── other-after.png ├── other-confirm.png └── middleware-initial.png ├── doc.go ├── go.mod ├── .gitignore ├── .github └── workflows │ └── test.yml ├── htmxrequest.go ├── context.go ├── .golangci.yml ├── go.sum ├── LICENSE ├── examples └── middleware │ ├── templates │ ├── index.html │ └── other.html │ └── main.go ├── middleware.go ├── hxswap.go ├── htmxresponse.go ├── headers.go ├── middleware_test.go └── README.md /images/eatme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusis/htmxtools/HEAD/images/eatme.png -------------------------------------------------------------------------------- /images/other-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusis/htmxtools/HEAD/images/other-1.png -------------------------------------------------------------------------------- /images/drinkme-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusis/htmxtools/HEAD/images/drinkme-1.png -------------------------------------------------------------------------------- /images/drinkme-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusis/htmxtools/HEAD/images/drinkme-2.png -------------------------------------------------------------------------------- /images/other-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusis/htmxtools/HEAD/images/other-after.png -------------------------------------------------------------------------------- /images/other-confirm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusis/htmxtools/HEAD/images/other-confirm.png -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package htmxtools contains helpers and such for working with htmx from go 2 | package htmxtools 3 | -------------------------------------------------------------------------------- /images/middleware-initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusis/htmxtools/HEAD/images/middleware-initial.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lusis/htmxtools 2 | 3 | go 1.20 4 | 5 | require github.com/stretchr/testify v1.8.4 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 11 | gopkg.in/yaml.v3 v3.0.1 // indirect 12 | ) 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | __debug_bin 9 | 10 | *.test 11 | *.out 12 | .DS_Store 13 | .vscode/ 14 | .idea/ 15 | dist/ 16 | bin/ 17 | cover.out 18 | cover.html 19 | .env 20 | config.yaml 21 | .cache/ 22 | go-sqlite3-test-* 23 | *.db 24 | data/ 25 | fly.toml 26 | *.sqlite 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | name: test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-go@v4 14 | with: 15 | go-version: 1.20.x 16 | - name: test-go 17 | run: go test -v ./... 18 | -------------------------------------------------------------------------------- /htmxrequest.go: -------------------------------------------------------------------------------- 1 | package htmxtools 2 | 3 | // HTMXRequest represents the htmx elements of an [http.Request] 4 | // fields may be empty strings 5 | type HTMXRequest struct { 6 | // https://htmx.org/attributes/hx-boost/ 7 | Boosted bool 8 | CurrentURL string 9 | HistoryRestore bool 10 | // https://htmx.org/attributes/hx-prompt/ 11 | Prompt string 12 | Target string 13 | TriggerName string 14 | Trigger string 15 | } 16 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package htmxtools 2 | 3 | import "context" 4 | 5 | type htmxContextKey string 6 | 7 | var requestContextKey = htmxContextKey("htmxRequest") 8 | 9 | // ToContext adds the HTMXRequest details to the provided parent context 10 | func (hr *HTMXRequest) ToContext(ctx context.Context) context.Context { 11 | if ctx == nil { 12 | ctx = context.TODO() 13 | } 14 | return context.WithValue(ctx, requestContextKey, hr) 15 | } 16 | 17 | // RequestFromContext parses the htmx request from the provided context 18 | func RequestFromContext(ctx context.Context) *HTMXRequest { 19 | res, ok := ctx.Value(requestContextKey).(*HTMXRequest) 20 | if !ok { 21 | return nil 22 | } 23 | return res 24 | } 25 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | skip-dirs-use-default: true 4 | 5 | exclude-rules: 6 | - path: proto 7 | - path: gen 8 | - path: _test\.go 9 | linters: 10 | - errcheck 11 | 12 | linters: 13 | disable-all: true 14 | enable: 15 | - errcheck 16 | - goconst 17 | - goimports 18 | - revive 19 | - gosimple 20 | - govet 21 | - ineffassign 22 | - staticcheck 23 | - nilerr 24 | - gofmt 25 | - misspell 26 | - nakedret 27 | - noctx 28 | - predeclared 29 | - whitespace 30 | - unparam 31 | - usestdlibvars 32 | 33 | issues: 34 | exclude-use-default: false 35 | exclude: 36 | - Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked 37 | exclude-rules: 38 | - path: _test\.go 39 | linters: 40 | - noctx 41 | 42 | linters-settings: 43 | goimports: 44 | local-prefixes: github.com/lusis 45 | gofmt: 46 | simplify: true 47 | nakedret: 48 | max-func-lines: 1 49 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 6 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 7 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= 8 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) <2023> John E. Vincent 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /examples/middleware/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ block "head" . }} 4 | 5 | 6 | 7 | 8 | 11 | 14 | Middleware Demo 15 | 16 | {{ end }} 17 | 18 | {{ block "body" . }} 19 | 20 | 21 | 29 |
31 |
32 | 33 |
34 |
35 |
36 | 37 | {{ end }} 38 | 39 | 40 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package htmxtools 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | // WrapFunc is middleware for inspecting http requests for htmx metadata 9 | func WrapFunc(next http.HandlerFunc) http.HandlerFunc { 10 | return func(w http.ResponseWriter, r *http.Request) { 11 | if res := ParseRequest(r); res != nil { 12 | ctx := res.ToContext(r.Context()) 13 | next.ServeHTTP(w, r.WithContext(ctx)) 14 | return 15 | } 16 | next.ServeHTTP(w, r) 17 | } 18 | } 19 | 20 | // Wrap is middleware for inspecting http requests for htmx metadata 21 | func Wrap(next http.Handler) http.Handler { 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | if res := ParseRequest(r); res != nil { 24 | ctx := res.ToContext(r.Context()) 25 | next.ServeHTTP(w, r.WithContext(ctx)) 26 | return 27 | } 28 | next.ServeHTTP(w, r) 29 | }) 30 | } 31 | 32 | // ParseRequest parses an [http.Request] for any htmx request headers and returns an [HTMXRequest] 33 | // fields will still have to be checked for empty string at call sites 34 | func ParseRequest(r *http.Request) *HTMXRequest { 35 | tru := "true" 36 | isHTMXRequest := strings.TrimSpace(r.Header.Get(HXRequestHeader.String())) == tru 37 | if !isHTMXRequest { 38 | return nil 39 | } 40 | res := &HTMXRequest{ 41 | Boosted: strings.TrimSpace(r.Header.Get(BoostedRequest.String())) == tru, 42 | CurrentURL: strings.TrimSpace(r.Header.Get(CurrentURLRequest.String())), 43 | HistoryRestore: strings.TrimSpace(r.Header.Get(HistoryRestoreRequest.String())) == tru, 44 | Prompt: strings.TrimSpace(r.Header.Get(PromptRequest.String())), 45 | Target: strings.TrimSpace(r.Header.Get(TargetRequest.String())), 46 | TriggerName: strings.TrimSpace(r.Header.Get(TriggerNameRequest.String())), 47 | Trigger: strings.TrimSpace(r.Header.Get(TriggerRequest.String())), 48 | } 49 | 50 | return res 51 | } 52 | -------------------------------------------------------------------------------- /examples/middleware/templates/other.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 13 | Other Page 14 | 15 | 16 | 17 | {{ block "other" . }} 18 |
19 | {{ if not .HtmxRequest }} 20 | 22 | {{ else }} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
Trigger: {{ .HtmxRequest.Trigger }}
Trigger Name: {{ .HtmxRequest.TriggerName }}
Target: {{ .HtmxRequest.Target }}
Prompt: {{ .HtmxRequest.Prompt }}
Current URL: {{ .HtmxRequest.CurrentURL }}
Boosted: {{ .HtmxRequest.Boosted }}
History Restore: {{ .HtmxRequest.HistoryRestore }}
54 | {{ end }} 55 |
56 | {{ end }} 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /hxswap.go: -------------------------------------------------------------------------------- 1 | package htmxtools 2 | 3 | // HXSwap - https://htmx.org/attributes/hx-swap/ 4 | type HXSwap int64 5 | 6 | const ( 7 | // SwapUnknown is the zero value for the enum 8 | SwapUnknown HXSwap = iota 9 | // SwapInnerHTML - The default, replace the inner html of the target element - probably no reason to ever set this explicitly 10 | SwapInnerHTML 11 | // SwapOuterHTML - Replace the entire target element with the response 12 | SwapOuterHTML 13 | // SwapBeforeBegin - Insert the response before the target element 14 | SwapBeforeBegin 15 | // SwapAfterBegin - Insert the response before the first child of the target element 16 | SwapAfterBegin 17 | // SwapBeforeEnd - Insert the response after the last child of the target element 18 | SwapBeforeEnd 19 | // SwapAfterEnd - Insert the response after the target element 20 | SwapAfterEnd 21 | // SwapDelete - Deletes the target element regardless of the response 22 | SwapDelete 23 | // SwapNone - Does not append content from response (out of band items will still be processed). 24 | SwapNone 25 | ) 26 | 27 | // HXSwapFromString returns an [HXSWap] from its string representation 28 | func HXSwapFromString(s string) HXSwap { 29 | switch s { 30 | case SwapInnerHTML.String(): 31 | return SwapInnerHTML 32 | case SwapOuterHTML.String(): 33 | return SwapOuterHTML 34 | case SwapBeforeBegin.String(): 35 | return SwapBeforeBegin 36 | case SwapAfterBegin.String(): 37 | return SwapAfterBegin 38 | case SwapAfterEnd.String(): 39 | return SwapAfterEnd 40 | case SwapDelete.String(): 41 | return SwapDelete 42 | case SwapNone.String(): 43 | return SwapNone 44 | default: 45 | return SwapInnerHTML 46 | } 47 | } 48 | 49 | // String returns the string representation of a status code 50 | func (hxs HXSwap) String() string { 51 | switch hxs { 52 | case SwapInnerHTML: 53 | return "innerHTML" 54 | case SwapOuterHTML: 55 | return "outerHTML" 56 | case SwapBeforeBegin: 57 | return "beforebegin" 58 | case SwapAfterBegin: 59 | return "afterbegin" 60 | case SwapBeforeEnd: 61 | return "beforeend" 62 | case SwapAfterEnd: 63 | return "afterend" 64 | case SwapDelete: 65 | return "delete" 66 | case SwapNone: 67 | return "none" 68 | default: 69 | return "innerHTML" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /htmxresponse.go: -------------------------------------------------------------------------------- 1 | package htmxtools 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | // HTMXResponse represents the htmx elements of an [http.Response] 10 | type HTMXResponse struct { 11 | // https://htmx.org/headers/hx-location/ 12 | Location string 13 | // https://htmx.org/headers/hx-push-url/ 14 | PushURL string 15 | Redirect string 16 | Refresh bool 17 | // https://htmx.org/headers/hx-replace-url/ 18 | ReplaceURL string 19 | // https://htmx.org/attributes/hx-swap/ 20 | Reswap HXSwap 21 | // https://htmx.org/headers/hx-trigger/ 22 | Trigger string 23 | // https://htmx.org/headers/hx-trigger/ 24 | TriggerAfterSettle string 25 | // https://htmx.org/headers/hx-trigger/ 26 | TriggerAfterSwap string 27 | } 28 | 29 | // AddToResponse adds the current state of the HTMXResponse to the http response headers 30 | func (hr *HTMXResponse) AddToResponse(w http.ResponseWriter) error { 31 | if strings.TrimSpace(hr.Location) != "" { 32 | w.Header().Set(LocationResponse.String(), hr.Location) 33 | } 34 | if strings.TrimSpace(hr.PushURL) != "" { 35 | w.Header().Set(PushURLResponse.String(), hr.PushURL) 36 | } 37 | if strings.TrimSpace(hr.Redirect) != "" { 38 | w.Header().Set(RedirectResponse.String(), hr.Redirect) 39 | } 40 | if hr.Refresh { 41 | w.Header().Set(RefreshResponse.String(), "true") 42 | } 43 | if strings.TrimSpace(hr.ReplaceURL) != "" { 44 | w.Header().Set(ReplaceURLResponse.String(), hr.ReplaceURL) 45 | } 46 | if strings.TrimSpace(hr.Trigger) != "" { 47 | w.Header().Set(TriggerResponse.String(), hr.Trigger) 48 | } 49 | if strings.TrimSpace(hr.TriggerAfterSettle) != "" { 50 | w.Header().Set(TriggerAfterSettleResponse.String(), hr.TriggerAfterSettle) 51 | } 52 | if hr.Reswap != 0 { 53 | w.Header().Set(ReswapResponse.String(), hr.Reswap.String()) 54 | } 55 | if strings.TrimSpace(hr.TriggerAfterSwap) != "" { 56 | w.Header().Set(TriggerAfterSwapResponse.String(), hr.TriggerAfterSwap) 57 | } 58 | return nil 59 | } 60 | 61 | // HXLocationResponse represents the structured format of an hx-location header described here: 62 | // https://htmx.org/headers/hx-location/ 63 | type HXLocationResponse struct { 64 | Path string `json:"path"` 65 | Source string `json:"source,omitempty"` 66 | Event string `json:"event,omitempty"` 67 | Handler string `json:"handler,omitempty"` 68 | Target string `json:"target,omitempty"` 69 | Swap HXSwap `json:"swap,omitempty"` 70 | Values map[string]interface{} `json:"value,omitempty"` 71 | Headers map[string]string `json:"headers,omitempty"` 72 | } 73 | 74 | // String strings 75 | func (hxl HXLocationResponse) String() string { 76 | j, _ := json.Marshal(hxl) 77 | return string(j) 78 | } 79 | -------------------------------------------------------------------------------- /examples/middleware/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "fmt" 7 | "html/template" 8 | "io/fs" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/lusis/htmxtools" 13 | "golang.org/x/exp/slog" 14 | ) 15 | 16 | //go:embed templates/* 17 | var templateFS embed.FS 18 | 19 | var templates *template.Template 20 | 21 | func main() { 22 | subbed, err := fs.Sub(templateFS, "templates") 23 | if err != nil { 24 | panic(err) 25 | } 26 | tmpls, err := template.New("").ParseFS(subbed, "*") 27 | if err != nil { 28 | panic(err) 29 | } 30 | templates = tmpls 31 | mux := http.NewServeMux() 32 | mux.HandleFunc("/alert", serversideAlert) 33 | mux.HandleFunc("/button-push", buttonPush) 34 | mux.HandleFunc("/", templateMiddleware) 35 | middleware := htmxtools.WrapFunc(requestLogger(mux)) 36 | if err := http.ListenAndServe(":3000", middleware); err != nil && err != http.ErrServerClosed { 37 | panic(err) 38 | } 39 | } 40 | 41 | func templateMiddleware(w http.ResponseWriter, r *http.Request) { 42 | // let's trim off the leftmost slash to find our template name 43 | p := strings.TrimLeft(r.URL.Path, "/") 44 | if p == "" { 45 | // server index by default 46 | p = "index.html" 47 | } 48 | htmxRequest := htmxtools.RequestFromContext(r.Context()) 49 | data := struct { 50 | HtmxRequest *htmxtools.HTMXRequest 51 | }{ 52 | HtmxRequest: htmxRequest, 53 | } 54 | 55 | if t := templates.Lookup(p); t != nil { 56 | if err := t.Execute(w, data); err != nil { 57 | slog.Error("unable to render template", "error", err) 58 | return 59 | } 60 | } 61 | } 62 | 63 | func serversideAlert(w http.ResponseWriter, r *http.Request) { 64 | if htmxRequest := htmxtools.RequestFromContext(r.Context()); htmxRequest != nil { 65 | // we're also going to ensure that this alert link doesn't make it's way into the history 66 | hxheaders := &htmxtools.HTMXResponse{ 67 | ReplaceURL: htmxRequest.CurrentURL, 68 | Trigger: `{"showMessage":{"level" : "info", "message" : "this alert was trigged via the HX-Trigger header"}}`, 69 | } 70 | if err := hxheaders.AddToResponse(w); err != nil { 71 | slog.Error(err.Error()) 72 | return 73 | } 74 | } 75 | } 76 | 77 | func buttonPush(w http.ResponseWriter, r *http.Request) { 78 | htmxRequest := htmxtools.RequestFromContext(r.Context()) 79 | if htmxRequest != nil { 80 | j, err := json.Marshal(htmxRequest) 81 | if err != nil { 82 | w.Write([]byte("unable to encode htmxrequest to json: " + err.Error())) // nolint: errcheck 83 | } 84 | response := ` 85 |
Your request:
%s
86 | ` 87 | w.Write([]byte(fmt.Sprintf(response, j))) // nolint: errcheck 88 | } 89 | } 90 | 91 | func requestLogger(next http.Handler) http.HandlerFunc { 92 | return func(w http.ResponseWriter, r *http.Request) { 93 | htmxreq := htmxtools.RequestFromContext(r.Context()) 94 | // log and continue 95 | slog.Info("handling request", "http.path", r.URL.Path, "http.host", r.Host, "http.method", r.Method, "http.client", r.Header.Get("User-Agent"), "content-type", r.Header.Get("content-type"), "htmxrequest", htmxreq) 96 | next.ServeHTTP(w, r) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /headers.go: -------------------------------------------------------------------------------- 1 | package htmxtools 2 | 3 | // HTMXRequestHeader is a string type 4 | type HTMXRequestHeader string 5 | 6 | // String strings 7 | func (rh HTMXRequestHeader) String() string { 8 | return string(rh) 9 | } 10 | 11 | // HTMXResponseHeader is a string type 12 | type HTMXResponseHeader string 13 | 14 | // String strings 15 | func (rh HTMXResponseHeader) String() string { 16 | return string(rh) 17 | } 18 | 19 | // https://htmx.org/reference/#headers 20 | // http headers are supposed to be case sensitive but 21 | // in case something isn't behaving somewhere, we'll use the 22 | // case from the project's page 23 | const ( 24 | hxHeaderPrefix = "HX-" 25 | // Request headers 26 | // HXRequestHeader is the header that signals an htmx request. Always true if called from htmx 27 | HXRequestHeader HTMXRequestHeader = hxHeaderPrefix + "Request" 28 | // BoostedRequest indicates that the request is via an element using hx-boost 29 | BoostedRequest HTMXRequestHeader = hxHeaderPrefix + "Boosted" 30 | // CurrentURLRequest the current URL of the browser 31 | CurrentURLRequest HTMXRequestHeader = hxHeaderPrefix + "Current-URL" 32 | // HistoryRestoreRequest is true if the request is for history restoration after a miss in the local history cache 33 | HistoryRestoreRequest HTMXRequestHeader = hxHeaderPrefix + "History-Restore-Request" 34 | // PromptRequest is the user response to an hx-prompt 35 | // https://htmx.org/attributes/hx-prompt/ 36 | PromptRequest HTMXRequestHeader = hxHeaderPrefix + "Prompt" 37 | // TriggerRequest is the id of the target element if it exists 38 | TriggerRequest HTMXRequestHeader = hxHeaderPrefix + "Trigger" 39 | // TriggerNameRequest is the name of the triggered element if it exists 40 | TriggerNameRequest HTMXRequestHeader = hxHeaderPrefix + "Trigger-Name" 41 | // TargetRequest is the id of the target element if it exists 42 | TargetRequest HTMXRequestHeader = hxHeaderPrefix + "Target" 43 | 44 | // Response headers 45 | // LocationResponse Allows you to do a client-side redirect that does not do a full page reload 46 | // https://htmx.org/headers/hx-location/ 47 | LocationResponse HTMXResponseHeader = hxHeaderPrefix + "Location" 48 | // PushURLResponse pushes a new url into the history stack 49 | // https://htmx.org/headers/hx-push-url/ 50 | PushURLResponse HTMXResponseHeader = hxHeaderPrefix + "Push-Url" 51 | // RedirectResponse can be used to do a client-side redirect to a new location 52 | RedirectResponse HTMXResponseHeader = hxHeaderPrefix + "Redirect" 53 | // RefreshResponse if set to “true” the client side will do a a full refresh of the page 54 | RefreshResponse HTMXResponseHeader = hxHeaderPrefix + "Refresh" 55 | // ReplaceURLResponse replaces the current URL in the location bar 56 | // https://htmx.org/headers/hx-replace-url/ 57 | ReplaceURLResponse HTMXResponseHeader = hxHeaderPrefix + "Replace-Url" 58 | // ReswapResponse Allows you to specify how the response will be swapped. See hx-swap for possible values 59 | ReswapResponse HTMXResponseHeader = hxHeaderPrefix + "Reswap" 60 | // RetargetResponse A CSS selector that updates the target of the content update to a different element on the page 61 | RetargetResponse HTMXResponseHeader = hxHeaderPrefix + "Retarget" 62 | // TriggerResponse allows you to trigger client side events, see the documentation for more info 63 | // https://htmx.org/headers/hx-trigger/ 64 | TriggerResponse HTMXResponseHeader = hxHeaderPrefix + "Trigger" 65 | // TriggerAfterSettleResponse allows you to trigger client side events, see the documentation for more info 66 | // https://htmx.org/headers/hx-trigger/ 67 | TriggerAfterSettleResponse HTMXResponseHeader = hxHeaderPrefix + "Trigger-After-Settle" 68 | // TriggerAfterSwapResponse allows you to trigger client side events, see the documentation for more info 69 | // https://htmx.org/headers/hx-trigger/ 70 | TriggerAfterSwapResponse HTMXResponseHeader = hxHeaderPrefix + "Trigger-After-Swap" 71 | ) 72 | -------------------------------------------------------------------------------- /middleware_test.go: -------------------------------------------------------------------------------- 1 | package htmxtools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestParseRequest(t *testing.T) { 15 | t.Parallel() 16 | type testcase struct { 17 | httpRequest *http.Request 18 | htmxRequest *HTMXRequest 19 | } 20 | validRequest, _ := http.NewRequest(http.MethodGet, "localhost", nil) 21 | validRequest.Header.Set(HXRequestHeader.String(), "true") 22 | fullRequest := validRequest.Clone(context.TODO()) 23 | fullRequest.Header.Add(BoostedRequest.String(), "true") 24 | fullRequest.Header.Add(CurrentURLRequest.String(), "localhost") 25 | fullRequest.Header.Add(HistoryRestoreRequest.String(), "true") 26 | fullRequest.Header.Add(PromptRequest.String(), "did you do it?") 27 | fullRequest.Header.Add(TriggerRequest.String(), "add-thing") 28 | fullRequest.Header.Add(TargetRequest.String(), "target-div") 29 | fullRequest.Header.Add(TriggerNameRequest.String(), "thing-id") 30 | testcases := map[string]testcase{ 31 | "nil": {httpRequest: &http.Request{}}, 32 | "validrequest": {httpRequest: validRequest, htmxRequest: &HTMXRequest{}}, 33 | "fullrequest": {httpRequest: fullRequest, htmxRequest: &HTMXRequest{ 34 | Boosted: true, 35 | CurrentURL: "localhost", 36 | HistoryRestore: true, 37 | Prompt: "did you do it?", 38 | Target: "target-div", 39 | Trigger: "add-thing", 40 | TriggerName: "thing-id", 41 | }}, 42 | } 43 | for n, tc := range testcases { 44 | t.Run(n, func(t *testing.T) { 45 | res := ParseRequest(tc.httpRequest) 46 | if tc.htmxRequest == nil { 47 | require.Nil(t, res) 48 | } else { 49 | require.NotNil(t, res) 50 | require.Equal(t, tc.htmxRequest.Boosted, res.Boosted, "boosted should match") 51 | require.Equal(t, tc.htmxRequest.CurrentURL, res.CurrentURL, "current url should match") 52 | require.Equal(t, tc.htmxRequest.HistoryRestore, res.HistoryRestore, "history restore should match") 53 | require.Equal(t, tc.htmxRequest.Prompt, res.Prompt, "prompt should match") 54 | require.Equal(t, tc.htmxRequest.Target, res.Target, "target should match") 55 | require.Equal(t, tc.htmxRequest.Trigger, res.Trigger, "trigger should match") 56 | require.Equal(t, tc.htmxRequest.TriggerName, res.TriggerName, "trigger name should match") 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestMiddleware(t *testing.T) { 63 | t.Parallel() 64 | htmxReq := &HTMXRequest{ 65 | Boosted: true, 66 | CurrentURL: "localhost", 67 | HistoryRestore: true, 68 | Prompt: "did you do it?", 69 | Target: "target-div", 70 | Trigger: "add-thing", 71 | TriggerName: "thing-id", 72 | } 73 | var extractedRequest *HTMXRequest 74 | // need a func to wrap with the middleware and capture the context details 75 | testHandlerFunc := func(w http.ResponseWriter, r *http.Request) { 76 | extractedRequest = RequestFromContext(r.Context()) 77 | } 78 | server := httptest.NewServer(WrapFunc(testHandlerFunc)) 79 | req, err := http.NewRequest(http.MethodGet, server.URL, nil) 80 | require.NoError(t, err, "new request should not error") 81 | require.NotNil(t, req, "http request should not be nil") 82 | // do the request without the headers first 83 | nres, nerr := http.DefaultClient.Do(req) 84 | require.NoError(t, nerr, "request should not error") 85 | require.NotNil(t, nres, "result should not be nil") 86 | require.Nil(t, extractedRequest, "extracted result should be nil") 87 | // add the htmx header 88 | req.Header.Set(HXRequestHeader.String(), "true") 89 | req.Header.Add(BoostedRequest.String(), "true") 90 | req.Header.Add(CurrentURLRequest.String(), "localhost") 91 | req.Header.Add(HistoryRestoreRequest.String(), "true") 92 | req.Header.Add(PromptRequest.String(), "did you do it?") 93 | req.Header.Add(TriggerRequest.String(), "add-thing") 94 | req.Header.Add(TargetRequest.String(), "target-div") 95 | req.Header.Add(TriggerNameRequest.String(), "thing-id") 96 | res, err := http.DefaultClient.Do(req) 97 | require.NoError(t, err, "making request should not error") 98 | require.NotNil(t, res, "http request result should not be nil") 99 | require.NotNil(t, extractedRequest, "request from context should not be nil") 100 | require.Equal(t, htmxReq.Boosted, extractedRequest.Boosted) 101 | } 102 | 103 | // addHeaders adds hmtx headers to the provided http request for testing 104 | func (hr *HTMXResponse) addHeaders(r *http.Request, headers ...map[HTMXRequestHeader]string) error { // nolint: unused 105 | for _, h := range headers { 106 | for k, v := range h { 107 | if strings.TrimSpace(r.Header.Get(k.String())) != "" { 108 | return fmt.Errorf("header already set: %s: %s", k, v) 109 | } 110 | r.Header.Set(k.String(), v) 111 | } 112 | } 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # htmxtools 2 | `htmxtools` is a collection of constants and utilities for working with [htmx](https://htmx.org) from Go. 3 | 4 | `htmx` plays REALLY nice with Go and (somewhat less nice but still nice) Go templates. 5 | 6 | 7 | ## General usage 8 | There are different things in here for different use cases - http middleware as well as some constants and helpers for htmx requests 9 | 10 | 11 | ### Constants and such 12 | There are quite a few constants and enums for some various bits of htmx that can help cut down on some error prone duplication. 13 | 14 | #### Response Headers 15 | The response headers can be used as described here: https://htmx.org/reference/#response_headers and they are one of the coolest things about htmx. 16 | 17 | You can do things like ensure the browser url bar and history point to a real html page (as opposed to a fragment which is the default when using hx-get and the like). 18 | 19 | I use this exact pattern in another project when an add or delete call is made to the backend (inside my handler): 20 | ```go 21 | w.Header().Add(htmxtools.LocationResponse.String(), `{"path":"delete-status-fragment", "target":"#content-div"}`) 22 | w.Header().Add(htmxtools.ReplaceURLResponse.String(), "status.html") 23 | w.WriteHeader(http.StatusAccepted) 24 | ``` 25 | When the handler is called via an `hx-` request, the contents `delete-status-fragment` will replace the contents of the div with id `content-div`. 26 | However, unlike the default behaviour, the url bar will show `status.html` and be safe to reload while the default would show a path of `delete-status-fragment` which is not a valid full html page 27 | 28 | _if you feel like the headers aren't working, make sure you actually wrote them to the http response. I make this mistake ALL THE TIME_ 29 | 30 | There's also a helper if you want for building the headers in a safer way: 31 | 32 | ```go 33 | if htmxRequest := htmxtools.RequestFromContext(r.Context()); htmxRequest != nil { 34 | hxheaders := &htmxtools.HTMXResponse{ 35 | ReplaceURL: htmxRequest.CurrentURL, 36 | Reswap: htmxtools.SwapOuterHTML, 37 | } 38 | if err := hxheaders.AddToResponse(w); err != nil { 39 | return 40 | } 41 | } 42 | ``` 43 | 44 | ### Middleware 45 | ```go 46 | http.Handle("/", htmxtools.Wrap(myhandler)) 47 | ``` 48 | or 49 | ```go 50 | http.Handle("/",htmxtools.WrapFunc(myhandlerfunc)) 51 | ``` 52 | 53 | This will detect htmx requests and inject the details into the context. 54 | You can extract the details down the line via: 55 | ```go 56 | func somefunc(w http.ResponseWriter, r *http.Request) { 57 | htmxrequest := htmxtools.RequestFromContext(r.Context()) 58 | if htmxrequest == nil { 59 | // do something for non-htmx requests 60 | } else { 61 | // do something for htmx requests 62 | // note that not all fields will be populated so you'll want to check 63 | } 64 | } 65 | ``` 66 | 67 | ## Examples 68 | The following contains a few different ways to use this library 69 | 70 | ### in-repo example 71 | In the `examples/middleware` directory there's a small example: 72 | ``` 73 | go run examples/middleware/main.go 74 | ``` 75 | 76 | will start a small webserver on http://localhost:3000 77 | 78 | - Loading the page will present a button, that when clicked, makes an htmx request to the backend which returns details about the htmx request: 79 | 80 | ![main image page with two buttons labeled "drink me" and "eat me"](images/middleware-initial.png?raw=true "Main page") 81 | 82 | - Clicking "drink me" will ask for input and render a response from the server: 83 | 84 | ![drink me button dialog](images/drinkme-1.png?raw=true)![drink me button response from server](images/drinkme-2.png?raw=true) 85 | visiting http://localhost:3000/other.html will show a similar page which doesn't go to the backend for data but passes through the middleware which injects the htmx request details in the template 86 | 87 | - Clicking "eat me" will respond with a server-side generated htmx alert via headers: 88 | ![browser alert dialog](images/eatme.png?raw=true) 89 | 90 | - Visting http://localhost:3000/other.html , will render a page that operates entirely client side (with the exception of the template values that are injected by the middleware) 91 | 92 | ![initial other page with a button labled "Generate htmx request"](images/other-1.png?raw=true) 93 | 94 | - clicking the button will generate an htmx request for the same file (with `hx-confirm` client side) but will be passed an htmx request struct via the template execution: 95 | 96 | ![initial other page with confirmation dialog presented](images/other-confirm.png?raw=true) 97 | ![initial other page post confirm with an html table showing the htmx request data](images/other-after.png?raw=true) 98 | 99 | ### Building your own handler 100 | If you wanted to, you can use the various bits to build your handler the way you want: 101 | 102 | ```go 103 | // responds to htmx requests with the provided template updating the provided target 104 | // replace is the path to replace in the url bar 105 | func hxOnlyHandler(template, target string, next http.Handler, replace string) http.HandlerFunc { 106 | return func(w http.ResponseWriter, r *http.Request) { 107 | htmxrequest := htmxtools.RequestFromContext(r.Context()) 108 | if htmxrequest == nil { 109 | next.ServeHTTP(w, r) 110 | return 111 | } 112 | htmxloc := fmt.Sprintf(`{"path":"%s","target":"%s"}`, template, target) 113 | w.Header().Add(htmxtools.LocationResponse.String(), htmxloc) 114 | if strings.TrimSpace(replace) != "" { 115 | w.Header().Add(htmxtools.ReplaceURLResponse.String(), replace) 116 | } 117 | w.WriteHeader(http.StatusAccepted) 118 | } 119 | } 120 | ``` 121 | 122 | ## Templating tips 123 | I am not a go template expert. I've tended to avoid them in the past because of the runtime implications however now that I've been working on browser content, I had to really dive back in. 124 | 125 | ### Use blocks 126 | Blocks are a REALLY REALLY nice thing in go templates. They allow you to define a template inside of another templare. Note that blocks are still rendered (unless something inside the block says not to). 127 | 128 | *tl;dr: wrap chunks of html content that you might want to reuse via htmx in a ```{{ block "unique-block-name" .}}blahblah{{end}}```* and call them by block name via `hx-get` (note that this requires your server to understand serving templates - example below) 129 | 130 | Take the following template (`index.html`) from this repo: 131 | ```html 132 | 133 | 134 | {{ block "head" . }} 135 | 136 | 137 | 138 | 141 | 144 | Middleware Demo 145 | 146 | {{ end }} 147 | 148 | {{ block "body" . }} 149 | 150 | 158 |
160 |
161 | 162 |
163 |
164 |
165 | 166 | {{ end }} 167 | 168 | ``` 169 | 170 | This is our index page and can be rendered as a whole html page as is. 171 | Now let's take a look at another file in the same directory: 172 | 173 | ```html 174 | 175 | 176 | 177 | 178 | 179 | 180 | 183 | 186 | Other Page 187 | 188 | 189 | 190 | {{ block "other" . }} 191 |
192 | {{ if not .HtmxRequest }} 193 | 194 | {{ else }} 195 | 196 | 197 | {{ end }} 198 |
199 | {{ end }} 200 | 201 | 202 | ``` 203 | 204 | If we wanted to, we could rewrite the above template like so: 205 | ```html 206 | 207 | 208 | 209 | {{ template "head" . }} 210 | 211 | 212 | {{ block "other" . }} 213 |
214 | {{ if not .HtmxRequest }} 215 | 216 | {{ else }} 217 | 218 | 219 | {{ end }} 220 |
221 | {{ end }} 222 | 223 | 224 | ``` 225 | 226 | This is non-htmx specific mind you but it DOES open up some fun tricks with htmx. Imagine we have the following html 227 | 228 | ```html 229 | 230 | 231 | 232 | 233 | 234 | 235 | 238 | 241 | Other Page 242 | 243 | 244 | 245 | {{ block "list-items" . }} 246 |
247 | 248 | 249 | ``` 250 | 251 | Now let's say we want the `list-items` html to be rendered somewhere else. We can do this now: 252 | 253 | ```html 254 | 255 | 256 | 257 | 258 | 259 | 260 | 263 | 266 | Third Page 267 | 268 | 269 | 270 |
271 | 272 | 273 | ``` 274 | 275 | The above div (`other-list-items`) will be replaced with the contents of the `list-items` block from above. 276 | 277 | Note that doing so will, by default, update the url and location history to contain `http://hostname/list-items` which is NOT a valid html page and would just render the table only. To work around this, set the `HX-Replace-Url` header to set to a valid page or `false`. You can also set the attribute on the element as well via [`hx-replace-url`](https://htmx.org/attributes/hx-replace-url/) 278 | 279 | 280 | ## TODO 281 | 282 | - add structs for htmx json (i.e. json passed to `HX-Location`) --------------------------------------------------------------------------------