Last active
December 28, 2023 18:48
-
-
Save peterhellberg/60dcccab932f8446bacd2ceb57ba603d to your computer and use it in GitHub Desktop.
A single Go file HTMX Todo list example, using no third party Go dependencies.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"context" | |
"crypto/rand" | |
"encoding/hex" | |
"fmt" | |
"html/template" | |
"net/http" | |
"os" | |
"strings" | |
"time" | |
) | |
func main() { | |
// Create a new TODO list | |
list := NewList() | |
// Example list of TODO items | |
list.Add("Bake a cake") | |
list.Add("Feed the cat") | |
list.Add("Take out the trash") | |
// Create a new service for the list | |
service := NewListService(list) | |
// Create a new handler for the service | |
handler := NewHandler(service) | |
// Get the port to listen on | |
port := getPort(os.Getenv) | |
// Create a new HTTP server for the handler | |
server := newHTTPServer(port, handler, 10*time.Second) | |
// Display the localhost address and port | |
fmt.Printf("Listening on http://localhost:%s\n", port) | |
// Start listening on the port | |
if err := server.ListenAndServe(); err != http.ErrServerClosed { | |
fmt.Println(err) | |
os.Exit(1) | |
} | |
} | |
func getPort(getenv func(string) string) string { | |
if port := getenv("PORT"); port != "" { | |
return port | |
} | |
return "6080" | |
} | |
func newHTTPServer(port string, handler http.Handler, dt time.Duration) *http.Server { | |
return &http.Server{ | |
Addr: ":" + port, | |
Handler: http.TimeoutHandler(handler, dt, "Request timed out"), | |
} | |
} | |
type Service interface { | |
Add(ctx context.Context, description string) (*Item, error) | |
Remove(ctx context.Context, id UUID) error | |
Update(ctx context.Context, id UUID, completed bool, description string) (*Item, error) | |
Search(ctx context.Context, search string) (List, error) | |
Get(ctx context.Context, id UUID) (*Item, error) | |
Sort(ctx context.Context, ids []UUID) error | |
List(ctx context.Context) (List, error) | |
} | |
type Store interface { | |
Add(description string) *Item | |
Remove(id UUID) | |
Update(id UUID, completed bool, description string) *Item | |
Search(search string) List | |
All() List | |
Get(id UUID) *Item | |
Reorder(ids []UUID) List | |
} | |
// | |
// Handler implementation | |
// | |
type Handler struct { | |
service Service | |
} | |
func NewHandler(service Service) *Handler { | |
return &Handler{ | |
service: service, | |
} | |
} | |
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
switch r.Method { | |
case http.MethodGet: | |
switch path := r.URL.Path; { | |
case path == "/": | |
h.Home(w, r) | |
case path == "/items": | |
h.Search(w, r) | |
case strings.HasPrefix(path, "/items/") && len(path) > 7: | |
h.Get(w, r) | |
case path == "/favicon.ico": | |
w.WriteHeader(http.StatusNoContent) | |
default: | |
h.bad(w, r) | |
} | |
case http.MethodPost: | |
first, rest, _ := strings.Cut(r.URL.Path[1:], "/") | |
if first != "items" { | |
w.WriteHeader(http.StatusBadRequest) | |
return | |
} | |
switch { | |
case len(rest) < 2: | |
h.Create(w, r) | |
case rest == "sort": | |
h.Sort(w, r) | |
case strings.HasSuffix(rest, "/edit"): | |
h.Update(w, r) | |
case strings.HasSuffix(rest, "/delete"): | |
h.Delete(w, r) | |
default: | |
h.bad(w, r) | |
} | |
case http.MethodPatch: | |
switch path := r.URL.Path; { | |
case strings.HasPrefix(path, "/items/") && len(path) > 7: | |
h.Update(w, r) | |
default: | |
h.bad(w, r) | |
} | |
case http.MethodDelete: | |
switch path := r.URL.Path; { | |
case strings.HasPrefix(path, "/items/") && len(path) > 7: | |
h.Delete(w, r) | |
default: | |
h.bad(w, r) | |
} | |
default: | |
h.bad(w, r) | |
} | |
} | |
func (h *Handler) bad(w http.ResponseWriter, r *http.Request) { | |
w.WriteHeader(http.StatusBadRequest) | |
} | |
func (h *Handler) Home(w http.ResponseWriter, r *http.Request) { | |
list, err := h.service.List(r.Context()) | |
if err != nil { | |
http.Error(w, err.Error(), http.StatusInternalServerError) | |
return | |
} | |
pageList.ExecuteTemplate(w, "page", struct { | |
Title string | |
Term string | |
List List | |
}{ | |
Title: "minimal-htmx-todo", | |
List: list, | |
}) | |
} | |
func (h *Handler) Sort(w http.ResponseWriter, r *http.Request) { | |
var itemIDs []UUID | |
if err := r.ParseForm(); err != nil { | |
http.Error(w, err.Error(), http.StatusBadRequest) | |
return | |
} | |
for _, id := range r.Form["id"] { | |
var itemID UUID | |
var err error | |
if itemID, err = ParseUUID(id); err != nil { | |
http.Error(w, err.Error(), http.StatusBadRequest) | |
return | |
} | |
itemIDs = append(itemIDs, itemID) | |
} | |
if err := h.service.Sort(r.Context(), itemIDs); err != nil { | |
http.Error(w, err.Error(), http.StatusInternalServerError) | |
return | |
} | |
if isHTMX(r) { | |
w.WriteHeader(http.StatusNoContent) | |
} else { | |
http.Redirect(w, r, "/", http.StatusFound) | |
} | |
} | |
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { | |
var term = r.URL.Query().Get("search") | |
list, err := h.service.Search(r.Context(), term) | |
if err != nil { | |
http.Error(w, err.Error(), http.StatusInternalServerError) | |
return | |
} | |
if isHTMX(r) { | |
page.ExecuteTemplate(w, "partials/list", list) | |
} else { | |
pageList.ExecuteTemplate(w, "page", struct { | |
Title string | |
List List | |
Term string | |
}{ | |
Title: fmt.Sprintf("Search for %q", term), | |
List: list, | |
Term: term, | |
}) | |
} | |
} | |
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { | |
if err := r.ParseForm(); err != nil { | |
http.Error(w, err.Error(), http.StatusBadRequest) | |
return | |
} | |
var description = r.Form.Get("description") | |
item, err := h.service.Add(r.Context(), description) | |
if err != nil { | |
http.Error(w, err.Error(), http.StatusInternalServerError) | |
return | |
} | |
if isHTMX(r) { | |
page.ExecuteTemplate(w, "partials/list/item", item) | |
} else { | |
http.Redirect(w, r, "/", http.StatusFound) | |
} | |
} | |
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { | |
var ( | |
itemID UUID | |
err error | |
) | |
if itemID, err = ParseUUID(idParam(r)); err != nil { | |
http.Error(w, err.Error(), http.StatusBadRequest) | |
return | |
} | |
if err := r.ParseForm(); err != nil { | |
http.Error(w, err.Error(), http.StatusBadRequest) | |
return | |
} | |
var ( | |
completed = r.Form.Get("completed") == "true" | |
description = r.Form.Get("description") | |
) | |
item, err := h.service.Update(r.Context(), itemID, completed, description) | |
if err != nil { | |
http.Error(w, err.Error(), http.StatusInternalServerError) | |
return | |
} | |
if isHTMX(r) { | |
page.ExecuteTemplate(w, "partials/list/item", item) | |
} else { | |
http.Redirect(w, r, "/", http.StatusFound) | |
} | |
} | |
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { | |
var ( | |
itemID UUID | |
err error | |
) | |
if itemID, err = ParseUUID(idParam(r)); err != nil { | |
http.Error(w, err.Error(), http.StatusBadRequest) | |
return | |
} | |
item, err := h.service.Get(r.Context(), itemID) | |
if err != nil { | |
http.Error(w, err.Error(), http.StatusInternalServerError) | |
return | |
} | |
if isHTMX(r) { | |
page.ExecuteTemplate(w, "partials/edit_item_form", item) | |
} else { | |
pageEdit.ExecuteTemplate(w, "page", struct { | |
Title string | |
Item Item | |
}{ | |
Title: "Edit", | |
Item: *item, | |
}) | |
} | |
} | |
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { | |
var ( | |
itemID UUID | |
err error | |
) | |
if itemID, err = ParseUUID(idParam(r)); err != nil { | |
http.Error(w, err.Error(), http.StatusBadRequest) | |
return | |
} | |
if err := h.service.Remove(r.Context(), itemID); err != nil { | |
http.Error(w, err.Error(), http.StatusInternalServerError) | |
return | |
} | |
if isHTMX(r) { | |
w.Write([]byte("")) | |
} else { | |
http.Redirect(w, r, "/", http.StatusFound) | |
} | |
} | |
func idParam(r *http.Request) string { | |
id, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/items/"), "/") | |
return id | |
} | |
func isHTMX(r *http.Request) bool { | |
// Check for "HX-Request" header | |
return r.Header.Get("HX-Request") != "" | |
} | |
// | |
// Service implementation | |
// | |
type ListService struct { | |
store Store | |
} | |
func NewListService(store Store) *ListService { | |
return &ListService{ | |
store: store, | |
} | |
} | |
func (s *ListService) Add(_ context.Context, description string) (*Item, error) { | |
return s.store.Add(description), nil | |
} | |
func (s *ListService) Remove(_ context.Context, id UUID) error { | |
s.store.Remove(id) | |
return nil | |
} | |
func (s *ListService) Update(_ context.Context, id UUID, completed bool, description string) (*Item, error) { | |
return s.store.Update(id, completed, description), nil | |
} | |
func (s *ListService) Search(_ context.Context, search string) (List, error) { | |
return s.store.Search(search), nil | |
} | |
func (s *ListService) Get(_ context.Context, id UUID) (*Item, error) { | |
item := s.store.Get(id) | |
if item == nil { | |
return nil, fmt.Errorf("no item found") | |
} | |
return item, nil | |
} | |
func (s *ListService) Sort(_ context.Context, ids []UUID) error { | |
s.store.Reorder(ids) | |
return nil | |
} | |
func (s *ListService) List(context.Context) (List, error) { | |
return s.store.All(), nil | |
} | |
// | |
// Store implementation | |
// | |
type List []*Item | |
func NewList() *List { | |
return &List{} | |
} | |
func (l *List) Add(description string) *Item { | |
item := NewItem(description) | |
*l = append(*l, item) | |
return item | |
} | |
func (l *List) Remove(id UUID) { | |
index := l.indexOf(id) | |
if index == -1 { | |
return | |
} | |
*l = append((*l)[:index], (*l)[index+1:]...) | |
} | |
func (l *List) Update(id UUID, completed bool, description string) *Item { | |
index := l.indexOf(id) | |
if index == -1 { | |
return nil | |
} | |
item := (*l)[index] | |
item.Update(completed, description) | |
return item | |
} | |
func (l *List) Search(search string) List { | |
list := make(List, 0) | |
for _, item := range *l { | |
if strings.Contains(item.Description, search) { | |
list = append(list, item) | |
} | |
} | |
return list | |
} | |
func (l *List) All() List { | |
list := make(List, len(*l)) | |
copy(list, *l) | |
return list | |
} | |
func (l *List) Get(id UUID) *Item { | |
index := l.indexOf(id) | |
if index == -1 { | |
return nil | |
} | |
return (*l)[index] | |
} | |
func (l *List) Reorder(ids []UUID) List { | |
newList := make(List, len(ids)) | |
for i, id := range ids { | |
newList[i] = (*l)[l.indexOf(id)] | |
} | |
copy(*l, newList) | |
return newList | |
} | |
func (l List) indexOf(id UUID) int { | |
for i, item := range l { | |
if item.ID == id { | |
return i | |
} | |
} | |
return -1 | |
} | |
type Item struct { | |
ID UUID | |
Description string | |
Completed bool | |
CreatedAt time.Time | |
} | |
func NewItem(description string) *Item { | |
return &Item{ | |
ID: NewUUID(), | |
Description: description, | |
Completed: false, | |
CreatedAt: time.Now(), | |
} | |
} | |
func (item *Item) Update(completed bool, description string) { | |
item.Completed = completed | |
item.Description = description | |
} | |
// | |
// Minimal UUID implementation, just for the example. | |
// You should likely use a library such as github.com/google/uuid | |
// | |
type UUID [16]byte | |
func NewUUID() UUID { | |
var uuid UUID | |
if _, err := rand.Read(uuid[:]); err != nil { | |
panic(err) | |
} | |
uuid[6] = (uuid[6] & 0x0f) | 0x40 // Version 4 | |
uuid[8] = (uuid[8] & 0x3f) | 0x80 // Variant 10 | |
return uuid | |
} | |
func ParseUUID(str string) (UUID, error) { | |
if len(str) != 36 { | |
return UUID{}, fmt.Errorf("invalid length for UUID: %d", len(str)) | |
} | |
if str[8] != '-' || str[13] != '-' || str[18] != '-' || str[23] != '-' { | |
return UUID{}, fmt.Errorf("UUID format invalid") | |
} | |
u := UUID{} | |
in := []byte(str) | |
if _, err := hex.Decode(u[:4], in[:8]); err != nil { | |
return UUID{}, fmt.Errorf("UUID did contain unexpected character in segment %d", 1) | |
} | |
if _, err := hex.Decode(u[4:6], in[9:13]); err != nil { | |
return UUID{}, fmt.Errorf("UUID did contain unexpected character in segment %d", 2) | |
} | |
if _, err := hex.Decode(u[6:8], in[14:18]); err != nil { | |
return UUID{}, fmt.Errorf("UUID did contain unexpected character in segment %d", 3) | |
} | |
if _, err := hex.Decode(u[8:10], in[19:23]); err != nil { | |
return UUID{}, fmt.Errorf("UUID did contain unexpected character in segment %d", 4) | |
} | |
if _, err := hex.Decode(u[10:16], in[24:36]); err != nil { | |
return UUID{}, fmt.Errorf("UUID did contain unexpected character in segment %d", 5) | |
} | |
return u, nil | |
} | |
func (u UUID) String() string { | |
return fmt.Sprintf("%x-%x-%x-%x-%x", u[:4], u[4:6], u[6:8], u[8:10], u[10:16]) | |
} | |
// Templates using html/template, for something a bit more convenient | |
// maybe take a look at https://templ.guide/ | |
var page = template.Must(template.New("page").Parse(`{{define "page" }}<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="UTF-8"/> | |
<title>{{ .Title }}</title> | |
<meta name="viewport" content="width=device-width, initial-scale=1"/> | |
<script src="https://unpkg.com/htmx.org@1.9.6/dist/htmx.min.js"></script> | |
<script src="https://unpkg.com/hyperscript.org@0.9.11/dist/_hyperscript.min.js"></script> | |
<script src="https://www.unpkg.com/sortablejs@1.15.0/Sortable.min.js"></script> | |
<script>` + js + `</script> | |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@next/css/pico.min.css" /> | |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@next/css/pico.colors.min.css" /> | |
<style>` + css + `</style> | |
</head> | |
<body> | |
<main class="container"> | |
<article class="grid"> | |
<div> | |
<hgroup> | |
<h1>minimal-htmx-todo</h1> | |
<h2> | |
A single <a href="https://go.dev/">Go</a> file | |
<a href="https://htmx.org/">HTMX</a> Todo list | |
example, using no third party dependencies. | |
</h2> | |
</hgroup> | |
{{template "content" . }} | |
</div> | |
</article> | |
</main> | |
</body> | |
</html>{{ end }}`)) | |
var js = ` | |
htmx.onLoad(function (content) { | |
var sortables = content.querySelectorAll(".sortable"); | |
for (var i = 0; i < sortables.length; i++) { | |
var sortable = sortables[i]; | |
new Sortable(sortable, { | |
draggable: '.draggable', | |
animation: 150, | |
chosenClass: 'dragClass' | |
}); | |
} | |
}); | |
` | |
var css = ` | |
.hidden { display: none; } | |
.line-through { | |
text-decoration: line-through dashed; | |
text-decoration-thickness: 0.3rem; | |
text-decoration-style: wavy; | |
text-decoration-color: #cccccc; | |
} | |
.sortable-ghost { | |
background-color: #13171f; | |
outline-style: dashed; | |
outline-color: var(--pico-border-color); | |
outline-width: thick; | |
} | |
strong { font-size: 1.5rem; } | |
` | |
var contentSearch = ` | |
<form method="GET" action="/items" class="[&:has(+ul:empty)]:hidden"> | |
<label> | |
<span>Search</span> | |
<input id="search" name="search" type="search" value="{{.}}" type="text" placeholder="Begin typing to search..." hx-get="/items" hx-target="#items" hx-trigger="keyup changed, search" hx-replace="innerHTML" /> | |
</label> | |
</form> | |
` | |
var contentAddItemForm = ` | |
<form method="POST" action="/items" hx-post="/items" hx-target="#no-items" hx-swap="beforebegin" > | |
<label> | |
<span>Add Item</span> | |
<input type="text" name="description" aria-invalid="false" data-script="on keyup if the event's key is 'Enter' set my value to '' trigger keyup" /> | |
</label> | |
</form> | |
` | |
var contentEditItemForm = ` | |
<div class="draggable"> | |
<input type="hidden" name="id" value="{{.ID}}" /> | |
<form method="POST" action="/items/{{.ID}}/edit" hx-target="closest div" hx-swap="outerHTML" hx-patch="/items/{{.ID}}" > | |
<input type="hidden" name="completed" {{if .Completed}}value="true"{{else}}value="false"{{end}}/> | |
<input type="text" name="description" value="{{.Description}}" /> | |
<input type="submit" class="hidden" /> | |
</form> | |
</div> | |
` | |
var contentList = ` | |
<form hx-post="/items/sort" hx-trigger="end"> | |
<div id="items" class="sortable"> | |
{{ range $item := . }} | |
{{template "partials/list/item" $item }} | |
{{ end }} | |
<div id="no-items" class="hidden"> | |
<p>Congrats, you have no more TODO items! Or... do you? 🤔</p> | |
</div> | |
</div> | |
</form> | |
` | |
var contentListItem = `<div class="draggable"> | |
<nav> | |
<ul style="width: 100%;"> | |
<li> | |
<form method="GET" action="/items/{{.ID}}"> | |
<button type="submit" hx-target="closest div" hx-swap="outerHTML" hx-get="/items/{{.ID}}" class="outline primary"> 📝 </button> | |
</form> | |
</li> | |
<li> | |
<form method="POST" action="/items/{{.ID}}/delete"> | |
<button type="submit" hx-target="closest div" hx-swap="outerHTML" hx-delete="/items/{{.ID}}" class="outline secondary"> ❌ </button> | |
</form> | |
</li> | |
<li style="width: 100%;"> | |
<form method="POST" action="/items/{{.ID}}/edit" hx-target="closest div" hx-swap="outerHTML" class="inline {{if .Completed}}line-through{{end}}"> | |
<input type="hidden" name="completed" {{if .Completed }}value="false"{{else}}value="true"{{end}}/> | |
<input type="hidden" name="description" value={{.Description}} /> | |
<noscript> | |
<input type="submit" {{if .Completed}}value="Set as Not Completed"{{else}}value="Set as Completed"{{end}}/> | |
</noscript> | |
<strong> | |
<span hx-patch="/items/{{.ID}}"> | |
{{ .Description }} | |
</span> | |
</strong> | |
</form> | |
<input type="hidden" name="id" value="{{.ID}}" /> | |
</li> | |
</ul> | |
</nav> | |
</div>` | |
var ( | |
_ = template.Must(page.New("partials/search").Parse(contentSearch)) | |
_ = template.Must(page.New("partials/add_item_form").Parse(contentAddItemForm)) | |
_ = template.Must(page.New("partials/edit_item_form").Parse(contentEditItemForm)) | |
_ = template.Must(page.New("partials/list").Parse(contentList)) | |
_ = template.Must(page.New("partials/list/item").Parse(contentListItem)) | |
pageEdit = template.Must(template.Must(page.Clone()).New("pages/edit").Parse(`{{ define "content" }} | |
{{ template "partials/edit_item_form" .Item }} | |
{{ end }}`)) | |
pageList = template.Must(template.Must(page.Clone()).New("pages/list").Parse(`{{ define "content" }} | |
{{ template "partials/search" .Term }} | |
{{ template "partials/add_item_form" }} | |
{{ template "partials/list" .List }} | |
{{ end }}`)) | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Clone into a temporary directory (using
mktemp -d
) and listen on a random port:wget
and thengo run
instead, listen on port6080
:wget https://gist.githubusercontent.com/peterhellberg/60dcccab932f8446bacd2ceb57ba603d/raw/40fba2a11243a0f1c213965b7759e3681ac53bac/minimal-htmx-todo.go && go run minimal-htmx-todo.go