Skip to content

Instantly share code, notes, and snippets.

@peterhellberg
Last active December 28, 2023 18:48
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save peterhellberg/60dcccab932f8446bacd2ceb57ba603d to your computer and use it in GitHub Desktop.
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.
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 }}`))
)
@peterhellberg
Copy link
Author

peterhellberg commented Oct 9, 2023

Clone into a temporary directory (using mktemp -d) and listen on a random port:

cd $(mktemp -d) && git clone https://gist.github.com/60dcccab932f8446bacd2ceb57ba603d.git . && PORT=$((9999+$RANDOM)) go run minimal-htmx-todo.go

wget and then go run instead, listen on port 6080:

wget https://gist.githubusercontent.com/peterhellberg/60dcccab932f8446bacd2ceb57ba603d/raw/40fba2a11243a0f1c213965b7759e3681ac53bac/minimal-htmx-todo.go && go run minimal-htmx-todo.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment