├── .editorconfig
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── cmd
└── example
│ └── main.go
├── go.mod
├── go.sum
├── htmx.go
├── htmx_test.go
├── http
├── http.go
└── http_test.go
├── internal
└── assert
│ └── assert.go
└── logo.png
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_size = 2
7 | indent_style = space
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [Makefile]
12 | indent_style = tab
13 |
14 | [{*.go,*.md}]
15 | indent_style = tab
16 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | concurrency:
12 | group: ${{ github.workflow }}-${{ github.ref_name }}
13 | cancel-in-progress: true
14 |
15 | jobs:
16 | test:
17 | name: Test
18 | runs-on: ubuntu-latest
19 |
20 | strategy:
21 | matrix:
22 | go:
23 | - "1.18"
24 | - "1.19"
25 | - "1.20"
26 | - "1.21"
27 | - "1.22"
28 | - "1.23"
29 |
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v4
33 |
34 | - name: Setup Go
35 | uses: actions/setup-go@v5
36 | with:
37 | go-version: ${{ matrix.go }}
38 | check-latest: true
39 |
40 | - name: Build
41 | run: go build -v ./...
42 |
43 | - name: Test
44 | run: go test -v -coverprofile=coverage.txt -shuffle on ./...
45 |
46 | lint:
47 | name: Lint
48 | runs-on: ubuntu-latest
49 | steps:
50 | - name: Checkout
51 | uses: actions/checkout@v4
52 |
53 | - name: Setup Go
54 | uses: actions/setup-go@v5
55 | with:
56 | go-version-file: go.mod
57 | check-latest: true
58 |
59 | - name: Lint
60 | uses: golangci/golangci-lint-action@v6
61 | with:
62 | version: latest
63 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | cover.out
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Maragu ApS
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: cover
2 | cover:
3 | go tool cover -html=cover.out
4 |
5 | .PHONY: lint
6 | lint:
7 | golangci-lint run
8 |
9 | .PHONY: test
10 | test:
11 | go test -coverprofile=cover.out -shuffle on ./...
12 |
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # gomponents-htmx
2 |
3 |
4 |
5 | [](https://pkg.go.dev/maragu.dev/gomponents-htmx)
6 | [](https://github.com/maragudk/gomponents-htmx/actions/workflows/ci.yml)
7 |
8 | [HTMX](https://htmx.org) attributes and helpers for [gomponents](https://www.gomponents.com).
9 |
10 | Made with ✨sparkles✨ by [maragu](https://www.maragu.dev/).
11 |
12 | Does your company depend on this project? [Contact me at markus@maragu.dk](mailto:markus@maragu.dk?Subject=Supporting%20your%20project) to discuss options for a one-time or recurring invoice to ensure its continued thriving.
13 |
14 | ## Usage
15 |
16 | ```shell
17 | go get maragu.dev/gomponents-htmx
18 | ```
19 |
20 | ```go
21 | package main
22 |
23 | import (
24 | "errors"
25 | "log"
26 | "net/http"
27 | "time"
28 |
29 | . "maragu.dev/gomponents"
30 | . "maragu.dev/gomponents/components"
31 | . "maragu.dev/gomponents/html"
32 | . "maragu.dev/gomponents/http"
33 |
34 | hx "maragu.dev/gomponents-htmx"
35 | hxhttp "maragu.dev/gomponents-htmx/http"
36 | )
37 |
38 | func main() {
39 | if err := start(); err != nil {
40 | log.Fatalln("Error:", err)
41 | }
42 | }
43 |
44 | func start() error {
45 | now := time.Now()
46 | mux := http.NewServeMux()
47 | mux.HandleFunc("/", Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
48 | if r.Method == http.MethodPost && hxhttp.IsBoosted(r.Header) {
49 | now = time.Now()
50 |
51 | hxhttp.SetPushURL(w.Header(), "/?time="+now.Format(timeOnly))
52 |
53 | return partial(now), nil
54 | }
55 | return page(now), nil
56 | }))
57 |
58 | log.Println("Starting on http://localhost:8080")
59 | if err := http.ListenAndServe("localhost:8080", mux); err != nil && !errors.Is(err, http.ErrServerClosed) {
60 | return err
61 | }
62 | return nil
63 | }
64 |
65 | const timeOnly = "15:04:05"
66 |
67 | func page(now time.Time) Node {
68 | return HTML5(HTML5Props{
69 | Title: now.Format(timeOnly),
70 |
71 | Head: []Node{
72 | Script(Src("https://cdn.tailwindcss.com?plugins=forms,typography")),
73 | Script(Src("https://unpkg.com/htmx.org")),
74 | },
75 |
76 | Body: []Node{
77 | Div(Class("max-w-7xl mx-auto p-4 prose lg:prose-lg xl:prose-xl"),
78 | H1(Text(`gomponents + HTMX`)),
79 |
80 | P(Textf(`Time at last full page refresh was %v.`, now.Format(timeOnly))),
81 |
82 | partial(now),
83 |
84 | Form(Method("post"), Action("/"),
85 | hx.Boost("true"), hx.Target("#partial"), hx.Swap("outerHTML"),
86 |
87 | Button(Type("submit"), Text(`Update time`),
88 | Class("rounded-md border border-transparent bg-orange-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"),
89 | ),
90 | ),
91 | ),
92 | },
93 | })
94 | }
95 |
96 | func partial(now time.Time) Node {
97 | return P(ID("partial"), Textf(`Time was last updated at %v.`, now.Format(timeOnly)))
98 | }
99 | ```
100 |
--------------------------------------------------------------------------------
/cmd/example/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "log"
6 | "net/http"
7 | "time"
8 |
9 | . "maragu.dev/gomponents"
10 | . "maragu.dev/gomponents/components"
11 | . "maragu.dev/gomponents/html"
12 | . "maragu.dev/gomponents/http"
13 |
14 | hx "maragu.dev/gomponents-htmx"
15 | hxhttp "maragu.dev/gomponents-htmx/http"
16 | )
17 |
18 | func main() {
19 | if err := start(); err != nil {
20 | log.Fatalln("Error:", err)
21 | }
22 | }
23 |
24 | func start() error {
25 | now := time.Now()
26 | mux := http.NewServeMux()
27 | mux.HandleFunc("/", Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
28 | if r.Method == http.MethodPost && hxhttp.IsBoosted(r.Header) {
29 | now = time.Now()
30 |
31 | hxhttp.SetPushURL(w.Header(), "/?time="+now.Format(timeOnly))
32 |
33 | return partial(now), nil
34 | }
35 | return page(now), nil
36 | }))
37 |
38 | log.Println("Starting on http://localhost:8080")
39 | if err := http.ListenAndServe("localhost:8080", mux); err != nil && !errors.Is(err, http.ErrServerClosed) {
40 | return err
41 | }
42 | return nil
43 | }
44 |
45 | const timeOnly = "15:04:05"
46 |
47 | func page(now time.Time) Node {
48 | return HTML5(HTML5Props{
49 | Title: now.Format(timeOnly),
50 |
51 | Head: []Node{
52 | Script(Src("https://cdn.tailwindcss.com?plugins=forms,typography")),
53 | Script(Src("https://unpkg.com/htmx.org")),
54 | },
55 |
56 | Body: []Node{
57 | Div(Class("max-w-7xl mx-auto p-4 prose lg:prose-lg xl:prose-xl"),
58 | H1(Text(`gomponents + HTMX`)),
59 |
60 | P(Textf(`Time at last full page refresh was %v.`, now.Format(timeOnly))),
61 |
62 | partial(now),
63 |
64 | Form(Method("post"), Action("/"),
65 | hx.Boost("true"), hx.Target("#partial"), hx.Swap("outerHTML"),
66 |
67 | Button(Type("submit"), Text(`Update time`),
68 | Class("rounded-md border border-transparent bg-orange-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"),
69 | ),
70 | ),
71 | ),
72 | },
73 | })
74 | }
75 |
76 | func partial(now time.Time) Node {
77 | return P(ID("partial"), Textf(`Time was last updated at %v.`, now.Format(timeOnly)))
78 | }
79 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module maragu.dev/gomponents-htmx
2 |
3 | go 1.18
4 |
5 | require maragu.dev/gomponents v1.0.0
6 |
7 | require maragu.dev/is v0.2.0
8 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | maragu.dev/gomponents v1.0.0 h1:eeLScjq4PqP1l+r5z/GC+xXZhLHXa6RWUWGW7gSfLh4=
2 | maragu.dev/gomponents v1.0.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
3 | maragu.dev/is v0.2.0 h1:poeuVEA5GG3vrDpGmzo2KjWtIMZmqUyvGnOB0/pemig=
4 | maragu.dev/is v0.2.0/go.mod h1:bviaM5S0fBshCw7wuumFGTju/izopZ/Yvq4g7Klc7y8=
5 |
--------------------------------------------------------------------------------
/htmx.go:
--------------------------------------------------------------------------------
1 | // Package htmx provides HTMX attributes and helpers for gomponents.
2 | // See https://htmx.org/
3 | package htmx
4 |
5 | import (
6 | "io"
7 |
8 | g "maragu.dev/gomponents"
9 | )
10 |
11 | // Boost to add or remove progressive enhancement for links and forms.
12 | // See https://htmx.org/attributes/hx-boost
13 | func Boost(v string) g.Node {
14 | return attr("boost", v)
15 | }
16 |
17 | // Get from the specified URL.
18 | // See https://htmx.org/attributes/hx-get
19 | func Get(url string) g.Node {
20 | return attr("get", url)
21 | }
22 |
23 | // On handles any event with a script inline.
24 | // See https://htmx.org/attributes/hx-on
25 | func On(name string, v string) g.Node {
26 | return &rawAttr{name: "on:" + name, value: v}
27 | }
28 |
29 | // Post to the specified URL.
30 | // See https://htmx.org/attributes/hx-post
31 | func Post(url string) g.Node {
32 | return attr("post", url)
33 | }
34 |
35 | // PushURL into the browser location bar, creating a new history entry.
36 | // See https://htmx.org/attributes/hx-push-url
37 | func PushURL(v string) g.Node {
38 | return attr("push-url", v)
39 | }
40 |
41 | // Select content to swap in from a response.
42 | // See https://htmx.org/attributes/hx-select
43 | func Select(v string) g.Node {
44 | return attr("select", v)
45 | }
46 |
47 | // SelectOOB content to swap in from a response, out of band (somewhere other than the target).
48 | // See https://htmx.org/attributes/hx-select-oob
49 | func SelectOOB(v string) g.Node {
50 | return attr("select-oob", v)
51 | }
52 |
53 | // Swap controls how content is swapped in.
54 | // See https://htmx.org/attributes/hx-swap
55 | func Swap(v string) g.Node {
56 | return attr("swap", v)
57 | }
58 |
59 | // SwapOOB marks content in a response to be out of band (should swap in somewhere other than the target).
60 | // See https://htmx.org/attributes/hx-swap-oob
61 | func SwapOOB(v string) g.Node {
62 | return attr("swap-oob", v)
63 | }
64 |
65 | // Target specifies the target element to be swapped.
66 | // See https://htmx.org/attributes/hx-target
67 | func Target(v string) g.Node {
68 | return attr("target", v)
69 | }
70 |
71 | // Trigger specifies the event that triggers the request.
72 | // See https://htmx.org/attributes/hx-trigger
73 | func Trigger(v string) g.Node {
74 | return attr("trigger", v)
75 | }
76 |
77 | // Vals adds values to the parameters to submit with the request (JSON-formatted).
78 | // See https://htmx.org/attributes/hx-vals
79 | func Vals(v string) g.Node {
80 | return attr("vals", v)
81 | }
82 |
83 | // Confirm shows a confirm() dialog before issuing a request.
84 | // See https://htmx.org/attributes/hx-confirm
85 | func Confirm(v string) g.Node {
86 | return attr("confirm", v)
87 | }
88 |
89 | // Delete will issue a DELETE to the specified URL and swap the HTML into the DOM using a swap strategy.
90 | // See https://htmx.org/attributes/hx-delete
91 | func Delete(v string) g.Node {
92 | return attr("delete", v)
93 | }
94 |
95 | // Disable htmx processing for the given node and any children nodes.
96 | // See https://htmx.org/attributes/hx-disable
97 | func Disable(v string) g.Node {
98 | return attr("disable", v)
99 | }
100 |
101 | // Disable element until htmx request completes.
102 | // See https://htmx.org/attributes/hx-disabled-elt/
103 | func DisabledElt(v string) g.Node {
104 | return attr("disabled-elt", v)
105 | }
106 |
107 | // Disinherit controls and disables automatic attribute inheritance for child nodes.
108 | // See https://htmx.org/attributes/hx-disinherit
109 | func Disinherit(v string) g.Node {
110 | return attr("disinherit", v)
111 | }
112 |
113 | // Encoding changes the request encoding type.
114 | // See https://htmx.org/attributes/hx-encoding
115 | func Encoding(v string) g.Node {
116 | return attr("encoding", v)
117 | }
118 |
119 | // Ext sets extensions to use for this element.
120 | // See https://htmx.org/attributes/hx-ext
121 | func Ext(v string) g.Node {
122 | return attr("ext", v)
123 | }
124 |
125 | // Headers adds to the headers that will be submitted with the request.
126 | // See https://htmx.org/attributes/hx-headers
127 | func Headers(v string) g.Node {
128 | return attr("headers", v)
129 | }
130 |
131 | // History prevents sensitive data being saved to the history cache.
132 | // See https://htmx.org/attributes/hx-history
133 | func History(v string) g.Node {
134 | return attr("history", v)
135 | }
136 |
137 | // HistoryElt sets the element to snapshot and restore during history navigation.
138 | // See https://htmx.org/attributes/hx-history-elt
139 | func HistoryElt(v string) g.Node {
140 | return attr("history-elt", v)
141 | }
142 |
143 | // Include additional data in requests.
144 | // See https://htmx.org/attributes/hx-include
145 | func Include(v string) g.Node {
146 | return attr("include", v)
147 | }
148 |
149 | // Indicator sets the element to put the htmx-request class on during the request.
150 | // See https://htmx.org/attributes/hx-indicator
151 | func Indicator(v string) g.Node {
152 | return attr("indicator", v)
153 | }
154 |
155 | // Params filters the parameters that will be submitted with a request.
156 | // See https://htmx.org/attributes/hx-params
157 | func Params(v string) g.Node {
158 | return attr("params", v)
159 | }
160 |
161 | // Patch issues a PATCH to the specified URL.
162 | // See https://htmx.org/attributes/hx-patch
163 | func Patch(v string) g.Node {
164 | return attr("patch", v)
165 | }
166 |
167 | // Preserve specifies elements to keep unchanged between requests.
168 | // See https://htmx.org/attributes/hx-preserve
169 | func Preserve(v string) g.Node {
170 | return attr("preserve", v)
171 | }
172 |
173 | // Prompt shows a prompt() before submitting a request.
174 | // See https://htmx.org/attributes/hx-prompt
175 | func Prompt(v string) g.Node {
176 | return attr("prompt", v)
177 | }
178 |
179 | // Put issues a PUT to the specified URL.
180 | // See https://htmx.org/attributes/hx-put
181 | func Put(v string) g.Node {
182 | return attr("put", v)
183 | }
184 |
185 | // ReplaceURL replaces the URL in the browser location bar.
186 | // See https://htmx.org/attributes/hx-replace-url
187 | func ReplaceURL(v string) g.Node {
188 | return attr("replace-url", v)
189 | }
190 |
191 | // Request configures various aspects of the request.
192 | // See https://htmx.org/attributes/hx-request
193 | func Request(v string) g.Node {
194 | return attr("request", v)
195 | }
196 |
197 | // Sync controls how requests made by different elements are synchronized.
198 | // See https://htmx.org/attributes/hx-sync
199 | func Sync(v string) g.Node {
200 | return attr("sync", v)
201 | }
202 |
203 | // Validate forces elements to validate themselves before a request.
204 | // See https://htmx.org/attributes/hx-validate
205 | func Validate(v string) g.Node {
206 | return attr("validate", v)
207 | }
208 |
209 | func attr(name, value string) g.Node {
210 | return g.Attr("hx-"+name, value)
211 | }
212 |
213 | // rawAttr is an attribute that doesn't escape its value.
214 | type rawAttr struct {
215 | name string
216 | value string
217 | }
218 |
219 | func (r *rawAttr) Render(w io.Writer) error {
220 | _, err := w.Write([]byte(" hx-" + r.name + `="` + r.value + `"`))
221 | return err
222 | }
223 |
224 | func (r *rawAttr) Type() g.NodeType {
225 | return g.AttributeType
226 | }
227 |
--------------------------------------------------------------------------------
/htmx_test.go:
--------------------------------------------------------------------------------
1 | package htmx_test
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "testing"
7 |
8 | g "maragu.dev/gomponents"
9 | . "maragu.dev/gomponents/html"
10 |
11 | hx "maragu.dev/gomponents-htmx"
12 | "maragu.dev/gomponents-htmx/internal/assert"
13 | )
14 |
15 | func TestAttributes(t *testing.T) {
16 | cases := map[string]func(string) g.Node{
17 | "boost": hx.Boost,
18 | "get": hx.Get,
19 | "post": hx.Post,
20 | "push-url": hx.PushURL,
21 | "select": hx.Select,
22 | "select-oob": hx.SelectOOB,
23 | "swap": hx.Swap,
24 | "swap-oob": hx.SwapOOB,
25 | "target": hx.Target,
26 | "trigger": hx.Trigger,
27 | "vals": hx.Vals,
28 | "confirm": hx.Confirm,
29 | "delete": hx.Delete,
30 | "disable": hx.Disable,
31 | "disabled-elt": hx.DisabledElt,
32 | "disinherit": hx.Disinherit,
33 | "encoding": hx.Encoding,
34 | "ext": hx.Ext,
35 | "headers": hx.Headers,
36 | "history": hx.History,
37 | "history-elt": hx.HistoryElt,
38 | "include": hx.Include,
39 | "indicator": hx.Indicator,
40 | "params": hx.Params,
41 | "patch": hx.Patch,
42 | "preserve": hx.Preserve,
43 | "prompt": hx.Prompt,
44 | "put": hx.Put,
45 | "replace-url": hx.ReplaceURL,
46 | "request": hx.Request,
47 | "sync": hx.Sync,
48 | "validate": hx.Validate,
49 | }
50 |
51 | for name, fn := range cases {
52 | t.Run(fmt.Sprintf(`should output hx-%v="hat"`, name), func(t *testing.T) {
53 | n := g.El("div", fn("hat"))
54 | assert.Equal(t, fmt.Sprintf(`