Merge remote-tracking branch 'origin/master' into ws-testing

# Conflicts:
#	framework/assets/dist/htmgo.js
#	framework/h/attribute.go
This commit is contained in:
maddalax 2024-11-04 06:51:45 -06:00
commit 7d83d17159
237 changed files with 7517 additions and 1977 deletions

View file

@ -0,0 +1,52 @@
name: Build and Deploy htmgo auth example
on:
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:
- master # Trigger on pushes to master
paths:
- 'examples/simple-auth/**' # Trigger only if files in this directory change
- "framework-ui/**"
- "cli/**"
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get short commit hash
id: vars
run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)"
- name: Build Docker image
run: |
cd ./examples/simple-auth && docker build -t ghcr.io/${{ github.repository_owner }}/simple-auth:${{ steps.vars.outputs.short_sha }} .
- name: Tag as latest Docker image
run: |
docker tag ghcr.io/${{ github.repository_owner }}/simple-auth:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/simple-auth:latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push Docker image
run: |
docker push ghcr.io/${{ github.repository_owner }}/simple-auth:latest

View file

@ -5,9 +5,6 @@ on:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types: types:
- completed - completed
pull_request:
branches:
- master
workflow_dispatch: # Trigger on manual workflow_dispatch workflow_dispatch: # Trigger on manual workflow_dispatch
push: push:
branches: branches:

View file

@ -25,4 +25,9 @@ jobs:
run: cd ./framework && go mod download run: cd ./framework && go mod download
- name: Run Go tests - name: Run Go tests
run: cd ./framework && go test ./... run: cd ./framework && go test ./... -coverprofile=coverage.txt
- name: Upload results to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -6,7 +6,8 @@ on:
branches: branches:
- master # Trigger on pushes to master - master # Trigger on pushes to master
paths: paths:
- 'framework/**' # Trigger only if files in this directory change - 'framework/**'
- 'tools/html-to-htmgo/**'
jobs: jobs:
update-htmgo-dep: update-htmgo-dep:

View file

@ -1,6 +1,3 @@
> [!WARNING]
> htmgo is in alpha release and active development. API's may have breaking changes between versions. Please report any issues on GitHub.
## **htmgo** ## **htmgo**
### build simple and scalable systems with go + htmx ### build simple and scalable systems with go + htmx
@ -8,8 +5,13 @@
------- -------
[![Go Report Card](https://goreportcard.com/badge/github.com/maddalax/htmgo)](https://goreportcard.com/report/github.com/maddalax/htmgo) [![Go Report Card](https://goreportcard.com/badge/github.com/maddalax/htmgo)](https://goreportcard.com/report/github.com/maddalax/htmgo)
![Build](https://github.com/maddalax/htmgo/actions/workflows/run-framework-tests.yml/badge.svg) ![Build](https://github.com/maddalax/htmgo/actions/workflows/run-framework-tests.yml/badge.svg)
[![Go Reference](https://pkg.go.dev/badge/github.com/maddalax/htmgo/framework@v1.0.2/h.svg)](https://htmgo.dev/docs)
[![codecov](https://codecov.io/github/maddalax/htmgo/graph/badge.svg?token=ANPD11LSGN)](https://codecov.io/github/maddalax/htmgo)
[![Join Discord](https://img.shields.io/badge/Join%20Discord-gray?style=flat&logo=discord&logoColor=white&link=https://htmgo.dev/discord)](https://htmgo.dev/discord)
<sup>looking for a python version? check out: https://fastht.ml</sup>
**introduction:** **introduction:**
htmgo is a lightweight pure go way to build interactive websites / web applications using go & htmx. htmgo is a lightweight pure go way to build interactive websites / web applications using go & htmx.
@ -34,8 +36,7 @@ func IndexPage(ctx *h.RequestContext) *h.Page {
2. live reload (rebuilds css, go, ent schema, and routes upon change) 2. live reload (rebuilds css, go, ent schema, and routes upon change)
3. automatic page and partial registration based on file path 3. automatic page and partial registration based on file path
4. built in tailwindcss support, no need to configure anything by default 4. built in tailwindcss support, no need to configure anything by default
5. plugin architecture to include optional plugins to streamline development, such as http://entgo.io 5. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
6. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
**get started:** **get started:**

View file

@ -3,13 +3,19 @@ module github.com/maddalax/htmgo/cli/htmgo
go 1.23.0 go 1.23.0
require ( require (
github.com/dave/jennifer v1.7.1
github.com/fsnotify/fsnotify v1.7.0 github.com/fsnotify/fsnotify v1.7.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v1.0.3-0.20241101111125-af0091c370ed
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241101111125-af0091c370ed
golang.org/x/mod v0.21.0 golang.org/x/mod v0.21.0
golang.org/x/net v0.29.0 golang.org/x/sys v0.26.0
golang.org/x/sys v0.25.0
golang.org/x/tools v0.25.0 golang.org/x/tools v0.25.0
) )
require github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect require (
github.com/bmatcuk/doublestar/v4 v4.7.1
github.com/go-chi/chi/v5 v5.1.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,16 +1,34 @@
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.2 h1:yr31UEva2D7AggIhqkxgy6Ee7CspcbesILpvPE27pMw=
github.com/maddalax/htmgo/framework v1.0.2/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/framework v1.0.3-0.20241101111125-af0091c370ed h1:ShprbCL4MXmlfA8u6H6dnrNwIA2liBOvaD+n2fWQ4ts=
github.com/maddalax/htmgo/framework v1.0.3-0.20241101111125-af0091c370ed/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241101111125-af0091c370ed h1:lHCpp6eOCvpAKMoXx30KOucWKIM9zV5Gl8IgvFWmJlw=
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241101111125-af0091c370ed/go.mod h1:FraJsj3NRuLBQDk83ZVa+psbNRNLe+rajVtVhYMEme4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -3,11 +3,13 @@ package dirutil
import ( import (
"fmt" "fmt"
"github.com/bmatcuk/doublestar/v4" "github.com/bmatcuk/doublestar/v4"
"strings"
) )
func matchesAny(patterns []string, path string) bool { func matchesAny(patterns []string, path string) bool {
for _, pattern := range patterns { for _, pattern := range patterns {
matched, err := doublestar.Match(pattern, path) matched, err :=
doublestar.Match(strings.ReplaceAll(pattern, `\`, "/"), strings.ReplaceAll(path, `\`, "/"))
if err != nil { if err != nil {
fmt.Printf("Error matching pattern: %v\n", err) fmt.Printf("Error matching pattern: %v\n", err)
return false return false

View file

@ -9,20 +9,22 @@ import (
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets" "github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
"github.com/maddalax/htmgo/cli/htmgo/tasks/css" "github.com/maddalax/htmgo/cli/htmgo/tasks/css"
"github.com/maddalax/htmgo/cli/htmgo/tasks/downloadtemplate" "github.com/maddalax/htmgo/cli/htmgo/tasks/downloadtemplate"
"github.com/maddalax/htmgo/cli/htmgo/tasks/formatter"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process" "github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/cli/htmgo/tasks/reloader" "github.com/maddalax/htmgo/cli/htmgo/tasks/reloader"
"github.com/maddalax/htmgo/cli/htmgo/tasks/run" "github.com/maddalax/htmgo/cli/htmgo/tasks/run"
"log/slog" "log/slog"
"os" "os"
"strings" "strings"
"sync"
) )
const version = "1.0.4"
func main() { func main() {
done := RegisterSignals() needsSignals := true
commandMap := make(map[string]*flag.FlagSet) commandMap := make(map[string]*flag.FlagSet)
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate"} commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate", "format", "version"}
for _, command := range commands { for _, command := range commands {
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError) commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
@ -56,6 +58,15 @@ func main() {
slog.Debug("Running task:", slog.String("task", taskName)) slog.Debug("Running task:", slog.String("task", taskName))
slog.Debug("working dir:", slog.String("dir", process.GetWorkingDir())) slog.Debug("working dir:", slog.String("dir", process.GetWorkingDir()))
if taskName == "format" {
needsSignals = false
}
done := make(chan bool, 1)
if needsSignals {
done = RegisterSignals()
}
if taskName == "watch" { if taskName == "watch" {
fmt.Printf("Running in watch mode\n") fmt.Printf("Running in watch mode\n")
os.Setenv("ENV", "development") os.Setenv("ENV", "development")
@ -67,21 +78,9 @@ func main() {
fmt.Printf("Generating CSS...\n") fmt.Printf("Generating CSS...\n")
css.GenerateCss(process.ExitOnError) css.GenerateCss(process.ExitOnError)
wg := sync.WaitGroup{} // generate ast needs to be run after css generation
astgen.GenAst(process.ExitOnError)
wg.Add(1) run.EntGenerate()
go func() {
defer wg.Done()
astgen.GenAst(process.ExitOnError)
}()
wg.Add(1)
go func() {
defer wg.Done()
run.EntGenerate()
}()
wg.Wait()
fmt.Printf("Starting server...\n") fmt.Printf("Starting server...\n")
process.KillAll() process.KillAll()
@ -90,7 +89,22 @@ func main() {
}() }()
startWatcher(reloader.OnFileChange) startWatcher(reloader.OnFileChange)
} else { } else {
if taskName == "schema" { if taskName == "version" {
fmt.Printf("htmgo cli version %s\n", version)
os.Exit(0)
}
if taskName == "format" {
if len(os.Args) < 3 {
fmt.Println(fmt.Sprintf("Usage: htmgo format <file>"))
os.Exit(1)
}
file := os.Args[2]
if file == "." {
formatter.FormatDir(process.GetWorkingDir())
} else {
formatter.FormatFile(os.Args[2])
}
} else if taskName == "schema" {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter entity name:") fmt.Print("Enter entity name:")
text, _ := reader.ReadString('\n') text, _ := reader.ReadString('\n')
@ -104,10 +118,10 @@ func main() {
} else if taskName == "css" { } else if taskName == "css" {
_ = css.GenerateCss(process.ExitOnError) _ = css.GenerateCss(process.ExitOnError)
} else if taskName == "ast" { } else if taskName == "ast" {
css.GenerateCss(process.ExitOnError)
_ = astgen.GenAst(process.ExitOnError) _ = astgen.GenAst(process.ExitOnError)
} else if taskName == "run" { } else if taskName == "run" {
_ = astgen.GenAst(process.ExitOnError) run.MakeBuildable()
_ = css.GenerateCss(process.ExitOnError)
_ = run.Server(process.ExitOnError) _ = run.Server(process.ExitOnError)
} else if taskName == "template" { } else if taskName == "template" {
name := "" name := ""

View file

@ -2,15 +2,20 @@ package astgen
import ( import (
"fmt" "fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process" "github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/framework/h"
"go/ast" "go/ast"
"go/parser" "go/parser"
"go/token" "go/token"
"golang.org/x/mod/modfile" "golang.org/x/mod/modfile"
"io/fs"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"unicode"
) )
type Page struct { type Page struct {
@ -24,6 +29,7 @@ type Partial struct {
FuncName string FuncName string
Package string Package string
Import string Import string
Path string
} }
const GeneratedDirName = "__htmgo" const GeneratedDirName = "__htmgo"
@ -34,6 +40,36 @@ const ModuleName = "github.com/maddalax/htmgo/framework/h"
var PackageName = fmt.Sprintf("package %s", GeneratedDirName) var PackageName = fmt.Sprintf("package %s", GeneratedDirName)
var GeneratedFileLine = fmt.Sprintf("// Package %s THIS FILE IS GENERATED. DO NOT EDIT.", GeneratedDirName) var GeneratedFileLine = fmt.Sprintf("// Package %s THIS FILE IS GENERATED. DO NOT EDIT.", GeneratedDirName)
func toPascaleCase(input string) string {
words := strings.Split(input, "_")
for i := range words {
words[i] = strings.Title(strings.ToLower(words[i]))
}
return strings.Join(words, "")
}
func isValidGoVariableName(name string) bool {
// Variable name must not be empty
if name == "" {
return false
}
// First character must be a letter or underscore
if !unicode.IsLetter(rune(name[0])) && name[0] != '_' {
return false
}
// Remaining characters must be letters, digits, or underscores
for _, char := range name[1:] {
if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' {
return false
}
}
return true
}
func normalizePath(path string) string {
return strings.ReplaceAll(path, `\`, "/")
}
func sliceCommonPrefix(dir1, dir2 string) string { func sliceCommonPrefix(dir1, dir2 string) string {
// Use filepath.Clean to normalize the paths // Use filepath.Clean to normalize the paths
dir1 = filepath.Clean(dir1) dir1 = filepath.Clean(dir1)
@ -59,9 +95,9 @@ func sliceCommonPrefix(dir1, dir2 string) string {
// Return the longer one // Return the longer one
if len(slicedDir1) > len(slicedDir2) { if len(slicedDir1) > len(slicedDir2) {
return slicedDir1 return normalizePath(slicedDir1)
} }
return slicedDir2 return normalizePath(slicedDir2)
} }
func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial) bool) ([]Partial, error) { func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial) bool) ([]Partial, error) {
@ -103,7 +139,8 @@ func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial
if selectorExpr.Sel.Name == "Partial" { if selectorExpr.Sel.Name == "Partial" {
p := Partial{ p := Partial{
Package: node.Name.Name, Package: node.Name.Name,
Import: sliceCommonPrefix(cwd, strings.ReplaceAll(filepath.Dir(path), `\`, `/`)), Path: normalizePath(sliceCommonPrefix(cwd, path)),
Import: sliceCommonPrefix(cwd, normalizePath(filepath.Dir(path))),
FuncName: funcDecl.Name.Name, FuncName: funcDecl.Name.Name,
} }
if predicate(p) { if predicate(p) {
@ -169,8 +206,8 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
if selectorExpr.Sel.Name == "Page" { if selectorExpr.Sel.Name == "Page" {
pages = append(pages, Page{ pages = append(pages, Page{
Package: node.Name.Name, Package: node.Name.Name,
Import: strings.ReplaceAll(filepath.Dir(path), `\`, `/`), Import: normalizePath(filepath.Dir(path)),
Path: path, Path: normalizePath(path),
FuncName: funcDecl.Name.Name, FuncName: funcDecl.Name.Name,
}) })
break break
@ -254,12 +291,18 @@ func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
} }
func writePartialsFile() { func writePartialsFile() {
config := dirutil.GetConfig()
cwd := process.GetWorkingDir() cwd := process.GetWorkingDir()
partialPath := filepath.Join(cwd, "partials") partialPath := filepath.Join(cwd, "partials")
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool { partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
return partial.FuncName != "GetPartialFromContext" return partial.FuncName != "GetPartialFromContext"
}) })
partials = h.Filter(partials, func(partial Partial) bool {
return !dirutil.IsGlobExclude(partial.Path, config.AutomaticPartialRoutingIgnore)
})
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
return return
@ -317,6 +360,7 @@ func formatRoute(path string) string {
} }
func writePagesFile() { func writePagesFile() {
config := dirutil.GetConfig()
builder := NewCodeBuilder(nil) builder := NewCodeBuilder(nil)
builder.AppendLine(GeneratedFileLine) builder.AppendLine(GeneratedFileLine)
@ -326,6 +370,10 @@ func writePagesFile() {
pages, _ := findPublicFuncsReturningHPage("pages") pages, _ := findPublicFuncsReturningHPage("pages")
pages = h.Filter(pages, func(page Page) bool {
return !dirutil.IsGlobExclude(page.Path, config.AutomaticPageRoutingIgnore)
})
if len(pages) > 0 { if len(pages) > 0 {
builder.AddImport(ModuleName) builder.AddImport(ModuleName)
} }
@ -371,6 +419,66 @@ func writePagesFile() {
}) })
} }
func writeAssetsFile() {
cwd := process.GetWorkingDir()
config := dirutil.GetConfig()
slog.Debug("writing assets file", slog.String("cwd", cwd), slog.String("config", config.PublicAssetPath))
distAssets := filepath.Join(cwd, "assets", "dist")
hasAssets := false
builder := strings.Builder{}
builder.WriteString(`package assets`)
builder.WriteString("\n")
filepath.WalkDir(distAssets, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasPrefix(d.Name(), ".") {
return nil
}
path = strings.ReplaceAll(path, distAssets, "")
httpUrl := normalizePath(fmt.Sprintf("%s%s", config.PublicAssetPath, path))
path = normalizePath(path)
path = strings.ReplaceAll(path, "/", "_")
path = strings.ReplaceAll(path, "//", "_")
name := strings.ReplaceAll(path, ".", "_")
name = strings.ReplaceAll(name, "-", "_")
name = toPascaleCase(name)
if isValidGoVariableName(name) {
builder.WriteString(fmt.Sprintf(`const %s = "%s"`, name, httpUrl))
builder.WriteString("\n")
hasAssets = true
}
return nil
})
builder.WriteString("\n")
str := builder.String()
if hasAssets {
WriteFile(filepath.Join(GeneratedDirName, "assets", "assets-generated.go"), func(content *ast.File) string {
return str
})
}
}
func GetModuleName() string { func GetModuleName() string {
wd := process.GetWorkingDir() wd := process.GetWorkingDir()
modPath := filepath.Join(wd, "go.mod") modPath := filepath.Join(wd, "go.mod")
@ -392,6 +500,7 @@ func GenAst(flags ...process.RunFlag) error {
} }
writePartialsFile() writePartialsFile()
writePagesFile() writePagesFile()
writeAssetsFile()
WriteFile("__htmgo/setup-generated.go", func(content *ast.File) string { WriteFile("__htmgo/setup-generated.go", func(content *ast.File) string {

View file

@ -1,82 +0,0 @@
package astgen
// OrderedMap is a generic data structure that maintains the order of keys.
type OrderedMap[K comparable, V any] struct {
keys []K
values map[K]V
}
// Entries returns the key-value pairs in the order they were added.
func (om *OrderedMap[K, V]) Entries() []struct {
Key K
Value V
} {
entries := make([]struct {
Key K
Value V
}, len(om.keys))
for i, key := range om.keys {
entries[i] = struct {
Key K
Value V
}{
Key: key,
Value: om.values[key],
}
}
return entries
}
// NewOrderedMap creates a new OrderedMap.
func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] {
return &OrderedMap[K, V]{
keys: []K{},
values: make(map[K]V),
}
}
// Set adds or updates a key-value pair in the OrderedMap.
func (om *OrderedMap[K, V]) Set(key K, value V) {
// Check if the key already exists
if _, exists := om.values[key]; !exists {
om.keys = append(om.keys, key) // Append key to the keys slice if it's a new key
}
om.values[key] = value
}
// Get retrieves a value by key.
func (om *OrderedMap[K, V]) Get(key K) (V, bool) {
value, exists := om.values[key]
return value, exists
}
// Keys returns the keys in the order they were added.
func (om *OrderedMap[K, V]) Keys() []K {
return om.keys
}
// Values returns the values in the order of their keys.
func (om *OrderedMap[K, V]) Values() []V {
values := make([]V, len(om.keys))
for i, key := range om.keys {
values[i] = om.values[key]
}
return values
}
// Delete removes a key-value pair from the OrderedMap.
func (om *OrderedMap[K, V]) Delete(key K) {
if _, exists := om.values[key]; exists {
// Remove the key from the map
delete(om.values, key)
// Remove the key from the keys slice
for i, k := range om.keys {
if k == key {
om.keys = append(om.keys[:i], om.keys[i+1:]...)
break
}
}
}
}

View file

@ -7,16 +7,3 @@ import (
func PanicF(format string, args ...interface{}) { func PanicF(format string, args ...interface{}) {
panic(fmt.Sprintf(format, args...)) panic(fmt.Sprintf(format, args...))
} }
func Unique[T any](slice []T, key func(item T) string) []T {
var result []T
seen := make(map[string]bool)
for _, v := range slice {
k := key(v)
if _, ok := seen[k]; !ok {
seen[k] = true
result = append(result, v)
}
}
return result
}

View file

@ -0,0 +1,50 @@
package formatter
import (
"fmt"
"github.com/maddalax/htmgo/tools/html-to-htmgo/htmltogo"
"os"
"path/filepath"
"strings"
)
func FormatDir(dir string) {
files, err := os.ReadDir(dir)
if err != nil {
fmt.Printf("error reading dir: %s\n", err.Error())
return
}
for _, file := range files {
if file.IsDir() {
FormatDir(filepath.Join(dir, file.Name()))
} else {
FormatFile(filepath.Join(dir, file.Name()))
}
}
}
func FormatFile(file string) {
if !strings.HasSuffix(file, ".go") {
return
}
fmt.Printf("formatting file: %s\n", file)
source, err := os.ReadFile(file)
if err != nil {
fmt.Printf("error reading file: %s\n", err.Error())
return
}
str := string(source)
if !strings.Contains(str, "github.com/maddalax/htmgo/framework/h") {
return
}
parsed := htmltogo.Indent(str)
os.WriteFile(file, []byte(parsed), 0644)
return
}

View file

@ -12,7 +12,10 @@ func KillProcess(process CmdWithFlags) error {
if process.Cmd == nil || process.Cmd.Process == nil { if process.Cmd == nil || process.Cmd.Process == nil {
return nil return nil
} }
Run(NewRawCommand("killprocess", fmt.Sprintf("taskkill /F /T /PID %s", strconv.Itoa(process.Cmd.Process.Pid)))) err := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(process.Cmd.Process.Pid)).Run()
if err != nil {
fmt.Println(err)
}
time.Sleep(time.Millisecond * 50) time.Sleep(time.Millisecond * 50)
return nil return nil
} }

View file

@ -115,7 +115,7 @@ func OnShutdown() {
} }
} }
// give it a second // give it a second
time.Sleep(time.Second * 2) time.Sleep(time.Second * 1)
// force kill // force kill
KillAll() KillAll()
} }

View file

@ -9,10 +9,14 @@ import (
"os" "os"
) )
func Build() { func MakeBuildable() {
copyassets.CopyAssets() copyassets.CopyAssets()
astgen.GenAst(process.ExitOnError)
css.GenerateCss(process.ExitOnError) css.GenerateCss(process.ExitOnError)
astgen.GenAst(process.ExitOnError)
}
func Build() {
MakeBuildable()
process.RunOrExit(process.NewRawCommand("", "mkdir -p ./dist")) process.RunOrExit(process.NewRawCommand("", "mkdir -p ./dist"))

View file

@ -1,19 +1,12 @@
package run package run
import ( import (
"github.com/maddalax/htmgo/cli/htmgo/tasks/astgen"
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process" "github.com/maddalax/htmgo/cli/htmgo/tasks/process"
) )
func Setup() { func Setup() {
process.RunOrExit(process.NewRawCommand("", "go mod download")) process.RunOrExit(process.NewRawCommand("", "go mod download"))
process.RunOrExit(process.NewRawCommand("", "go mod tidy")) process.RunOrExit(process.NewRawCommand("", "go mod tidy"))
MakeBuildable()
copyassets.CopyAssets()
astgen.GenAst(process.ExitOnError)
css.GenerateCss(process.ExitOnError)
EntGenerate() EntGenerate()
} }

View file

@ -19,7 +19,7 @@ func ReplaceTextInFile(file string, text string, replacement string) error {
func ReplaceTextInDirRecursive(dir string, text string, replacement string, filter func(file string) bool) error { func ReplaceTextInDirRecursive(dir string, text string, replacement string, filter func(file string) bool) error {
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if filter(path) { if filter(filepath.Base(path)) {
_ = ReplaceTextInFile(path, text, replacement) _ = ReplaceTextInFile(path, text, replacement)
} }
return nil return nil

View file

@ -11,18 +11,25 @@ import (
func MessageRow(message *Message) *h.Element { func MessageRow(message *Message) *h.Element {
return h.Div( return h.Div(
h.Attribute("hx-swap-oob", "beforeend"), h.Attribute("hx-swap-oob", "beforeend"),
h.Class("flex flex-col gap-4 w-full break-words whitespace-normal"), // Ensure container breaks long words h.Class("flex flex-col gap-4 w-full break-words whitespace-normal"),
// Ensure container breaks long words
h.Id("messages"), h.Id("messages"),
h.Div( h.Div(
h.Class("flex flex-col gap-1"), h.Class("flex flex-col gap-1"),
h.Div( h.Div(
h.Class("flex gap-2 items-center"), h.Class("flex gap-2 items-center"),
h.Pf(message.UserName, h.Class("font-bold")), h.Pf(
message.UserName,
h.Class("font-bold"),
),
h.Pf(message.CreatedAt.In(time.Local).Format("01/02 03:04 PM")), h.Pf(message.CreatedAt.In(time.Local).Format("01/02 03:04 PM")),
), ),
h.Article( h.Article(
h.Class("break-words whitespace-normal"), // Ensure message text wraps correctly h.Class("break-words whitespace-normal"),
h.P(h.Text(message.Message)), // Ensure message text wraps correctly
h.P(
h.Text(message.Message),
),
), ),
), ),
) )

View file

@ -28,12 +28,28 @@ func Button(props ButtonProps) h.Ren {
text := h.Text(props.Text) text := h.Text(props.Text)
button := h.Button( button := h.Button(
h.If(props.Id != "", h.Id(props.Id)), h.If(
h.If(props.Children != nil, h.Children(props.Children...)), props.Id != "",
h.Id(props.Id),
),
h.If(
props.Children != nil,
h.Children(props.Children...),
),
h.Class("flex gap-1 items-center justify-center border p-4 rounded cursor-hover", props.Class), h.Class("flex gap-1 items-center justify-center border p-4 rounded cursor-hover", props.Class),
h.If(props.Get != "", h.Get(props.Get)), h.If(
h.If(props.Target != "", h.HxTarget(props.Target)), props.Get != "",
h.IfElse(props.Type != "", h.Type(props.Type), h.Type("button")), h.Get(props.Get),
),
h.If(
props.Target != "",
h.HxTarget(props.Target),
),
h.IfElse(
props.Type != "",
h.Type(props.Type),
h.Type("button"),
),
text, text,
) )

View file

@ -6,6 +6,9 @@ func FormError(error string) *h.Element {
return h.Div( return h.Div(
h.Id("form-error"), h.Id("form-error"),
h.Text(error), h.Text(error),
h.If(error != "", h.Class("p-4 bg-rose-400 text-white rounded")), h.If(
error != "",
h.Class("p-4 bg-rose-400 text-white rounded"),
),
) )
} }

View file

@ -19,11 +19,14 @@ type InputProps struct {
} }
func Input(props InputProps) *h.Element { func Input(props InputProps) *h.Element {
validation := h.If(props.ValidationPath != "", h.Children( validation := h.If(
h.Post(props.ValidationPath, hx.BlurEvent), props.ValidationPath != "",
h.Attribute("hx-swap", "innerHTML transition:true"), h.Children(
h.Attribute("hx-target", "next div"), h.Post(props.ValidationPath, hx.BlurEvent),
)) h.Attribute("hx-swap", "innerHTML transition:true"),
h.Attribute("hx-target", "next div"),
),
)
if props.Type == "" { if props.Type == "" {
props.Type = "text" props.Type = "text"
@ -32,18 +35,41 @@ func Input(props InputProps) *h.Element {
input := h.Input( input := h.Input(
props.Type, props.Type,
h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"), h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"),
h.If(props.Name != "", h.Name(props.Name)), h.If(
h.If(props.Children != nil, h.Children(props.Children...)), props.Name != "",
h.If(props.Required, h.Required()), h.Name(props.Name),
h.If(props.Placeholder != "", h.Placeholder(props.Placeholder)), ),
h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)), h.If(
props.Children != nil,
h.Children(props.Children...),
),
h.If(
props.Required,
h.Required(),
),
h.If(
props.Placeholder != "",
h.Placeholder(props.Placeholder),
),
h.If(
props.DefaultValue != "",
h.Attribute("value", props.DefaultValue),
),
validation, validation,
) )
wrapped := h.Div( wrapped := h.Div(
h.If(props.Id != "", h.Id(props.Id)), h.If(
props.Id != "",
h.Id(props.Id),
),
h.Class("flex flex-col gap-1"), h.Class("flex flex-col gap-1"),
h.If(props.Label != "", h.Label(h.Text(props.Label))), h.If(
props.Label != "",
h.Label(
h.Text(props.Label),
),
),
input, input,
h.Div( h.Div(
h.Id(props.Id+"-error"), h.Id(props.Id+"-error"),

View file

@ -5,7 +5,7 @@ go 1.23.0
require ( require (
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d
github.com/mattn/go-sqlite3 v1.14.23 github.com/mattn/go-sqlite3 v1.14.23
github.com/puzpuzpuz/xsync/v3 v3.4.0 github.com/puzpuzpuz/xsync/v3 v3.4.0
) )

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d h1:xr5dOwDzFZgZlgL3MmggSS9p+VeC0JawNS6tWBI3XUM=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View file

@ -17,13 +17,10 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
RootPage( RootPage(
h.Div( h.Div(
h.TriggerChildren(), h.TriggerChildren(),
h.Attribute("sse-connect", fmt.Sprintf("/sse/chat/%s", roomId)), h.Attribute("sse-connect", fmt.Sprintf("/sse/chat/%s", roomId)),
h.HxOnSseOpen( h.HxOnSseOpen(
js.ConsoleLog("Connected to chat room"), js.ConsoleLog("Connected to chat room"),
), ),
h.HxOnSseError( h.HxOnSseError(
js.EvalJs(fmt.Sprintf(` js.EvalJs(fmt.Sprintf(`
const reason = e.detail.event.data const reason = e.detail.event.data
@ -38,35 +35,27 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
} }
`, roomId, roomId)), `, roomId, roomId)),
), ),
// Adjusted flex properties for responsive layout // Adjusted flex properties for responsive layout
h.Class("flex flex-row h-screen bg-neutral-100 overflow-x-hidden"), h.Class("flex flex-row h-screen bg-neutral-100 overflow-x-hidden"),
// Collapse Button for mobile // Collapse Button for mobile
CollapseButton(), CollapseButton(),
// Sidebar for connected users // Sidebar for connected users
UserSidebar(), UserSidebar(),
h.Div( h.Div(
// Adjusted to fill height and width // Adjusted to fill height and width
h.Class("flex flex-col h-full w-full bg-white p-4 overflow-hidden"), h.Class("flex flex-col h-full w-full bg-white p-4 overflow-hidden"),
// Room name at the top, fixed // Room name at the top, fixed
CachedRoomHeader(ctx), CachedRoomHeader(ctx),
h.HxAfterSseMessage( h.HxAfterSseMessage(
js.EvalJsOnSibling("#messages", js.EvalJsOnSibling("#messages",
`element.scrollTop = element.scrollHeight;`), `element.scrollTop = element.scrollHeight;`),
), ),
// Chat Messages // Chat Messages
h.Div( h.Div(
h.Id("messages"), h.Id("messages"),
// Adjusted flex properties and removed max-width // Adjusted flex properties and removed max-width
h.Class("flex flex-col gap-4 mb-4 overflow-auto flex-grow w-full pt-[50px]"), h.Class("flex flex-col gap-4 mb-4 overflow-auto flex-grow w-full pt-[50px]"),
), ),
// Chat Input at the bottom // Chat Input at the bottom
Form(), Form(),
), ),
@ -91,7 +80,10 @@ func roomNameHeader(ctx *h.RequestContext) *h.Element {
} }
return h.Div( return h.Div(
h.Class("bg-neutral-700 text-white p-3 shadow-sm w-full fixed top-0 left-0 flex justify-center z-10"), h.Class("bg-neutral-700 text-white p-3 shadow-sm w-full fixed top-0 left-0 flex justify-center z-10"),
h.H2F(room.Name, h.Class("text-lg font-bold")), h.H2F(
room.Name,
h.Class("text-lg font-bold"),
),
h.Div( h.Div(
h.Class("absolute right-5 top-3 cursor-pointer"), h.Class("absolute right-5 top-3 cursor-pointer"),
h.Text("Share"), h.Text("Share"),
@ -108,7 +100,10 @@ func UserSidebar() *h.Element {
return h.Div( return h.Div(
h.Class("sidebar h-full pt-[67px] min-w-48 w-48 bg-neutral-200 p-4 flex-col justify-between gap-3 rounded-l-lg hidden md:flex"), h.Class("sidebar h-full pt-[67px] min-w-48 w-48 bg-neutral-200 p-4 flex-col justify-between gap-3 rounded-l-lg hidden md:flex"),
h.Div( h.Div(
h.H3F("Connected Users", h.Class("text-lg font-bold")), h.H3F(
"Connected Users",
h.Class("text-lg font-bold"),
),
chat.ConnectedUsers(make([]db.User, 0), ""), chat.ConnectedUsers(make([]db.User, 0), ""),
), ),
h.A( h.A(
@ -121,9 +116,11 @@ func UserSidebar() *h.Element {
func CollapseButton() *h.Element { func CollapseButton() *h.Element {
return h.Div( return h.Div(
h.Class("fixed top-0 left-4 md:hidden z-50"), // Always visible on mobile h.Class("fixed top-0 left-4 md:hidden z-50"),
// Always visible on mobile
h.Button( h.Button(
h.Class("p-2 text-2xl bg-neutral-700 text-white rounded-md"), // Styling the button h.Class("p-2 text-2xl bg-neutral-700 text-white rounded-md"),
// Styling the button
h.OnClick( h.OnClick(
js.EvalJs(` js.EvalJs(`
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
@ -131,13 +128,16 @@ func CollapseButton() *h.Element {
sidebar.classList.toggle('flex'); sidebar.classList.toggle('flex');
`), `),
), ),
h.UnsafeRaw("&#9776;"), // The icon for collapsing the sidebar h.UnsafeRaw("&#9776;"),
// The icon for collapsing the sidebar
), ),
) )
} }
func MessageInput() *h.Element { func MessageInput() *h.Element {
return h.Input("text", return h.Input(
"text",
h.Id("message-input"), h.Id("message-input"),
h.Required(), h.Required(),
h.Class("p-4 rounded-md border border-slate-200 w-full focus:outline-none focus:ring focus:ring-slate-200"), h.Class("p-4 rounded-md border border-slate-200 w-full focus:outline-none focus:ring focus:ring-slate-200"),

View file

@ -13,12 +13,14 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"), h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"),
h.Div( h.Div(
h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"), h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"),
h.H2F("htmgo chat", h.Class("text-3xl font-bold text-center mb-6")), h.H2F(
"htmgo chat",
h.Class("text-3xl font-bold text-center mb-6"),
),
h.Form( h.Form(
h.Attribute("hx-swap", "none"), h.Attribute("hx-swap", "none"),
h.PostPartial(partials.CreateOrJoinRoom), h.PostPartial(partials.CreateOrJoinRoom),
h.Class("flex flex-col gap-6"), h.Class("flex flex-col gap-6"),
// Username input at the top // Username input at the top
components.Input(components.InputProps{ components.Input(components.InputProps{
Id: "username", Id: "username",
@ -30,11 +32,9 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
h.MaxLength(15), h.MaxLength(15),
}, },
}), }),
// Single box for Create or Join a Chat Room // Single box for Create or Join a Chat Room
h.Div( h.Div(
h.Class("p-4 border border-gray-300 rounded-md flex flex-col gap-6"), h.Class("p-4 border border-gray-300 rounded-md flex flex-col gap-6"),
// Create New Chat Room input // Create New Chat Room input
components.Input(components.InputProps{ components.Input(components.InputProps{
Name: "new-chat-room", Name: "new-chat-room",
@ -45,15 +45,20 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
h.MaxLength(20), h.MaxLength(20),
}, },
}), }),
// OR divider // OR divider
h.Div( h.Div(
h.Class("flex items-center justify-center gap-4"), h.Class("flex items-center justify-center gap-4"),
h.Div(h.Class("border-t border-gray-300 flex-grow")), h.Div(
h.P(h.Text("OR"), h.Class("text-gray-500")), h.Class("border-t border-gray-300 flex-grow"),
h.Div(h.Class("border-t border-gray-300 flex-grow")), ),
h.P(
h.Text("OR"),
h.Class("text-gray-500"),
),
h.Div(
h.Class("border-t border-gray-300 flex-grow"),
),
), ),
// Join Chat Room input // Join Chat Room input
components.Input(components.InputProps{ components.Input(components.InputProps{
Id: "join-chat-room", Id: "join-chat-room",
@ -67,10 +72,8 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
}, },
}), }),
), ),
// Error message // Error message
components.FormError(""), components.FormError(""),
// Submit button at the bottom // Submit button at the bottom
components.PrimaryButton(components.ButtonProps{ components.PrimaryButton(components.ButtonProps{
Type: "submit", Type: "submit",

View file

@ -2,9 +2,15 @@ module hackernews
go 1.23.0 go 1.23.0
require github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d require (
github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d
github.com/microcosm-cc/bluemonday v1.0.27
)
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
golang.org/x/net v0.29.0 // indirect
) )

View file

@ -1,11 +1,17 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d h1:xr5dOwDzFZgZlgL3MmggSS9p+VeC0JawNS6tWBI3XUM=
github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=

View file

@ -5,6 +5,7 @@ import (
"github.com/maddalax/htmgo/framework/h" "github.com/maddalax/htmgo/framework/h"
"hackernews/internal/batch" "hackernews/internal/batch"
"hackernews/internal/httpjson" "hackernews/internal/httpjson"
"hackernews/internal/sanitize"
"hackernews/internal/timeformat" "hackernews/internal/timeformat"
"log/slog" "log/slog"
"strconv" "strconv"
@ -132,6 +133,8 @@ func GetComment(id int) (*Comment, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
c.Text = sanitize.Sanitize(c.Text)
c.By = sanitize.Sanitize(c.By)
c.Time = timeformat.ParseUnix(c.TimeRaw) c.Time = timeformat.ParseUnix(c.TimeRaw)
return c, nil return c, nil
} }
@ -141,6 +144,9 @@ func GetStory(id int) (*Story, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.Title = sanitize.Sanitize(s.Title)
s.Text = sanitize.Sanitize(s.Text)
s.By = sanitize.Sanitize(s.By)
s.Time = timeformat.ParseUnix(s.TimeRaw) s.Time = timeformat.ParseUnix(s.TimeRaw)
return s, nil return s, nil
} }

View file

@ -0,0 +1,9 @@
package sanitize
import "github.com/microcosm-cc/bluemonday"
var p = bluemonday.UGCPolicy()
func Sanitize(text string) string {
return p.Sanitize(text)
}

View file

@ -5,14 +5,17 @@ import (
) )
func RootPage(children ...h.Ren) h.Ren { func RootPage(children ...h.Ren) h.Ren {
banner := h.A(h.Class("bg-neutral-200 text-neutral-600 text-center p-2 flex items-center justify-center"), banner := h.A(
h.Class("bg-neutral-200 text-neutral-600 text-center p-2 flex items-center justify-center"),
h.Href("https://github.com/maddalax/htmgo"), h.Href("https://github.com/maddalax/htmgo"),
h.Attribute("target", "_blank"), h.Attribute("target", "_blank"),
h.Text("Built with htmgo.dev"), h.Text("Built with htmgo.dev"),
) )
return h.Html( return h.Html(
h.HxExtensions(h.BaseExtensions()), h.HxExtensions(
h.BaseExtensions(),
),
h.Head( h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"), h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Link("/public/favicon.ico", "icon"), h.Link("/public/favicon.ico", "icon"),

View file

@ -5,6 +5,7 @@ import (
"github.com/maddalax/htmgo/framework/h" "github.com/maddalax/htmgo/framework/h"
"hackernews/internal/batch" "hackernews/internal/batch"
"hackernews/internal/news" "hackernews/internal/news"
"hackernews/internal/sanitize"
"hackernews/internal/timeformat" "hackernews/internal/timeformat"
"strings" "strings"
"time" "time"
@ -13,7 +14,12 @@ import (
func StoryComments(ctx *h.RequestContext) *h.Partial { func StoryComments(ctx *h.RequestContext) *h.Partial {
return h.NewPartial( return h.NewPartial(
h.Fragment( h.Fragment(
h.OobSwap(ctx, h.Div(h.Id("comments-loader"))), h.OobSwap(
ctx,
h.Div(
h.Id("comments-loader"),
),
),
h.Div( h.Div(
h.Class("flex flex-col gap-3 prose max-w-none"), h.Class("flex flex-col gap-3 prose max-w-none"),
CachedStoryComments(news.MustItemId(ctx)), CachedStoryComments(news.MustItemId(ctx)),
@ -57,14 +63,20 @@ func Comment(item news.Comment, nesting int) *h.Element {
"border-b border-gray-200": nesting == 0, "border-b border-gray-200": nesting == 0,
"border-l border-gray-200": nesting > 0, "border-l border-gray-200": nesting > 0,
}), }),
h.If(nesting > 0, h.Attribute("style", fmt.Sprintf("margin-left: %dpx", (nesting-1)*15))), h.If(
nesting > 0,
h.Attribute("style", fmt.Sprintf("margin-left: %dpx", (nesting-1)*15)),
),
h.Div( h.Div(
h.If(nesting > 0, h.Class("pl-4")), h.If(
nesting > 0,
h.Class("pl-4"),
),
h.Div( h.Div(
h.Class("flex gap-1 items-center"), h.Class("flex gap-1 items-center"),
h.Div( h.Div(
h.Class("font-bold text-rose-500"), h.Class("font-bold text-rose-500"),
h.UnsafeRaw(item.By), h.UnsafeRaw(sanitize.Sanitize(item.By)),
), ),
h.Div( h.Div(
h.Class("text-sm text-gray-600"), h.Class("text-sm text-gray-600"),
@ -74,15 +86,18 @@ func Comment(item news.Comment, nesting int) *h.Element {
), ),
h.Div( h.Div(
h.Class("text-sm text-gray-600"), h.Class("text-sm text-gray-600"),
h.UnsafeRaw(strings.TrimSpace(item.Text)), h.UnsafeRaw(sanitize.Sanitize(strings.TrimSpace(item.Text))),
),
),
h.If(
len(children) > 0,
h.List(
children, func(child news.Comment, index int) *h.Element {
return h.Div(
Comment(child, nesting+1),
)
},
), ),
), ),
h.If(len(children) > 0, h.List(
children, func(child news.Comment, index int) *h.Element {
return h.Div(
Comment(child, nesting+1),
)
},
)),
) )
} }

View file

@ -6,6 +6,7 @@ import (
"hackernews/components" "hackernews/components"
"hackernews/internal/news" "hackernews/internal/news"
"hackernews/internal/parse" "hackernews/internal/parse"
"hackernews/internal/sanitize"
"hackernews/internal/timeformat" "hackernews/internal/timeformat"
"time" "time"
) )
@ -57,13 +58,18 @@ func StorySidebar(ctx *h.RequestContext) *h.Partial {
page := parse.MustParseInt(pageRaw, 0) page := parse.MustParseInt(pageRaw, 0)
fetchMorePath := h.GetPartialPathWithQs(StorySidebar, h.NewQs("mode", "infinite", "page", fmt.Sprintf("%d", page+1), "category", category)) fetchMorePath := h.GetPartialPathWithQs(
StorySidebar,
h.NewQs("mode", "infinite", "page", fmt.Sprintf("%d", page+1), "category", category),
)
list := CachedStoryList(category, page, 50, fetchMorePath) list := CachedStoryList(category, page, 50, fetchMorePath)
body := h.Aside( body := h.Aside(
h.Id("story-sidebar"), h.Id("story-sidebar"),
h.JoinExtensions(h.TriggerChildren()), h.JoinExtensions(
h.TriggerChildren(),
),
h.Class("sticky top-0 h-screen p-1 bg-gray-100 overflow-y-auto max-w-80 min-w-80"), h.Class("sticky top-0 h-screen p-1 bg-gray-100 overflow-y-auto max-w-80 min-w-80"),
h.Div( h.Div(
h.Class("flex flex-col gap-1"), h.Class("flex flex-col gap-1"),
@ -99,7 +105,9 @@ func SidebarTitle(defaultCategory string) *h.Element {
h.Text("Hacker News"), h.Text("Hacker News"),
), ),
h.Div( h.Div(
h.OnLoad(h.EvalJs(ScrollJs)), h.OnLoad(
h.EvalJs(ScrollJs),
),
h.Class("scroll-container mt-2 flex gap-1 no-scrollbar overflow-y-hidden whitespace-nowrap overflow-x-auto"), h.Class("scroll-container mt-2 flex gap-1 no-scrollbar overflow-y-hidden whitespace-nowrap overflow-x-auto"),
h.List(news.Categories, func(item news.Category, index int) *h.Element { h.List(news.Categories, func(item news.Category, index int) *h.Element {
return CategoryBadge(defaultCategory, item) return CategoryBadge(defaultCategory, item)
@ -114,7 +122,13 @@ func CategoryBadge(defaultCategory string, category news.Category) *h.Element {
category.Name, category.Name,
selected, selected,
h.Attribute("hx-swap", "none"), h.Attribute("hx-swap", "none"),
h.If(!selected, h.PostPartialOnClickQs(StorySidebar, h.NewQs("category", category.Path))), h.If(
!selected,
h.PostPartialOnClickQs(
StorySidebar,
h.NewQs("category", category.Path),
),
),
) )
} }
@ -129,7 +143,7 @@ var CachedStoryList = h.CachedPerKeyT4(time.Minute*5, func(category string, page
h.Class("block p-2 bg-white rounded-md shadow cursor-pointer"), h.Class("block p-2 bg-white rounded-md shadow cursor-pointer"),
h.Div( h.Div(
h.Class("font-bold"), h.Class("font-bold"),
h.UnsafeRaw(item.Title), h.UnsafeRaw(sanitize.Sanitize(item.Title)),
), ),
h.Div( h.Div(
h.Class("text-sm text-gray-600"), h.Class("text-sm text-gray-600"),

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/maddalax/htmgo/framework/h" "github.com/maddalax/htmgo/framework/h"
"hackernews/internal/news" "hackernews/internal/news"
"hackernews/internal/sanitize"
"hackernews/internal/timeformat" "hackernews/internal/timeformat"
"time" "time"
) )
@ -57,7 +58,7 @@ func StoryBody(story *news.Story) *h.Element {
h.Class("prose prose-2xl border-b border-gray-200 pb-3 max-w-none w-full"), h.Class("prose prose-2xl border-b border-gray-200 pb-3 max-w-none w-full"),
h.H5( h.H5(
h.Class("flex gap-2 items-left font-bold"), h.Class("flex gap-2 items-left font-bold"),
h.UnsafeRaw(story.Title), h.UnsafeRaw(sanitize.Sanitize(story.Title)),
), ),
h.A( h.A(
h.Href(story.Url), h.Href(story.Url),
@ -66,7 +67,7 @@ func StoryBody(story *news.Story) *h.Element {
), ),
h.Div( h.Div(
h.Class("text-sm text-gray-600"), h.Class("text-sm text-gray-600"),
h.UnsafeRaw(story.Text), h.UnsafeRaw(sanitize.Sanitize(story.Text)),
), ),
h.Div( h.Div(
h.Class("text-sm text-gray-600 mt-2"), h.Class("text-sm text-gray-600 mt-2"),

View file

@ -0,0 +1,11 @@
# Project exclude paths
/tmp/
node_modules/
dist/
js/dist
js/node_modules
go.work
go.work.sum
.idea
!framework/assets/dist
__htmgo

6
examples/simple-auth/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/assets/dist
tmp
node_modules
.idea
__htmgo
dist

View file

@ -0,0 +1,36 @@
# Stage 1: Build the Go binary
FROM golang:1.23 AS builder
# Set the working directory inside the container
WORKDIR /app
# Copy go.mod and go.sum files
COPY go.mod go.sum ./
# Download and cache the Go modules
RUN go mod download
# Copy the source code into the container
COPY . .
# Build the Go binary for Linux
RUN CGO_ENABLED=0 GOPRIVATE=github.com/maddalax LOG_LEVEL=debug go run github.com/maddalax/htmgo/cli/htmgo@latest build
RUN CGO_ENABLED=1 GOOS=linux go build -tags prod -o ./dist -a -ldflags '-linkmode external -extldflags "-static"' .
# Stage 2: Create the smallest possible image
FROM gcr.io/distroless/base-debian11
# Set the working directory inside the container
WORKDIR /app
# Copy the Go binary from the builder stage
COPY --from=builder /app/dist .
# Expose the necessary port (replace with your server port)
EXPOSE 3000
# Command to run the binary
CMD ["./simpleauth"]

View file

@ -0,0 +1,20 @@
version: '3'
tasks:
run:
cmds:
- htmgo run
silent: true
build:
cmds:
- htmgo build
docker:
cmds:
- docker build .
watch:
cmds:
- htmgo watch
silent: true

View file

@ -0,0 +1,13 @@
//go:build !prod
// +build !prod
package main
import (
"io/fs"
"simpleauth/internal/embedded"
)
func GetStaticAssets() fs.FS {
return embedded.NewOsFs()
}

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,16 @@
//go:build prod
// +build prod
package main
import (
"embed"
"io/fs"
)
//go:embed assets/dist/*
var staticAssets embed.FS
func GetStaticAssets() fs.FS {
return staticAssets
}

View file

@ -0,0 +1,14 @@
module simpleauth
go 1.23.0
require (
github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d
github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/crypto v0.28.0
)
require (
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
)

View file

@ -0,0 +1,20 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d h1:xr5dOwDzFZgZlgL3MmggSS9p+VeC0JawNS6tWBI3XUM=
github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,10 @@
# htmgo configuration
# if tailwindcss is enabled, htmgo will automatically compile your tailwind and output it to assets/dist
tailwind: true
# which directories to ignore when watching for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
watch_ignore: [".git", "node_modules", "dist/*"]
# files to watch for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
watch_files: ["**/*.go", "**/*.css", "**/*.md"]

View file

@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

View file

@ -0,0 +1,26 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package db
import (
"database/sql"
)
type Session struct {
ID int64
UserID int64
SessionID string
CreatedAt sql.NullString
ExpiresAt string
}
type User struct {
ID int64
Email string
Password string
Metadata interface{}
CreatedAt sql.NullString
UpdatedAt sql.NullString
}

View file

@ -0,0 +1,25 @@
package db
import (
"context"
"database/sql"
_ "embed"
_ "github.com/mattn/go-sqlite3"
)
//go:embed schema.sql
var ddl string
func Provide() *Queries {
db, err := sql.Open("sqlite3", "file:htmgo-user-example.db?cache=shared&_fk=1")
if err != nil {
panic(err)
}
if _, err := db.ExecContext(context.Background(), ddl); err != nil {
panic(err)
}
return New(db)
}

View file

@ -0,0 +1,31 @@
-- Queries for User Management
-- name: CreateUser :one
INSERT INTO user (email, password, metadata)
VALUES (?, ?, ?)
RETURNING id;
-- name: CreateSession :exec
INSERT INTO sessions (user_id, session_id, expires_at)
VALUES (?, ?, ?);
-- name: GetUserByToken :one
SELECT u.*
FROM user u
JOIN sessions t ON u.id = t.user_id
WHERE t.session_id = ?
AND t.expires_at > datetime('now');
-- name: GetUserByID :one
SELECT *
FROM user
WHERE id = ?;
-- name: GetUserByEmail :one
SELECT *
FROM user
WHERE email = ?;
-- name: UpdateUserMetadata :exec
UPDATE user SET metadata = json_patch(COALESCE(metadata, '{}'), ?) WHERE id = ?;

View file

@ -0,0 +1,123 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: queries.sql
package db
import (
"context"
)
const createSession = `-- name: CreateSession :exec
INSERT INTO sessions (user_id, session_id, expires_at)
VALUES (?, ?, ?)
`
type CreateSessionParams struct {
UserID int64
SessionID string
ExpiresAt string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
_, err := q.db.ExecContext(ctx, createSession, arg.UserID, arg.SessionID, arg.ExpiresAt)
return err
}
const createUser = `-- name: CreateUser :one
INSERT INTO user (email, password, metadata)
VALUES (?, ?, ?)
RETURNING id
`
type CreateUserParams struct {
Email string
Password string
Metadata interface{}
}
// Queries for User Management
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, createUser, arg.Email, arg.Password, arg.Metadata)
var id int64
err := row.Scan(&id)
return id, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password, metadata, created_at, updated_at
FROM user
WHERE email = ?
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Password,
&i.Metadata,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password, metadata, created_at, updated_at
FROM user
WHERE id = ?
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Password,
&i.Metadata,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByToken = `-- name: GetUserByToken :one
SELECT u.id, u.email, u.password, u.metadata, u.created_at, u.updated_at
FROM user u
JOIN sessions t ON u.id = t.user_id
WHERE t.session_id = ?
AND t.expires_at > datetime('now')
`
func (q *Queries) GetUserByToken(ctx context.Context, sessionID string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByToken, sessionID)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Password,
&i.Metadata,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateUserMetadata = `-- name: UpdateUserMetadata :exec
UPDATE user SET metadata = json_patch(COALESCE(metadata, '{}'), ?) WHERE id = ?
`
type UpdateUserMetadataParams struct {
JsonPatch interface{}
ID int64
}
func (q *Queries) UpdateUserMetadata(ctx context.Context, arg UpdateUserMetadataParams) error {
_, err := q.db.ExecContext(ctx, updateUserMetadata, arg.JsonPatch, arg.ID)
return err
}

View file

@ -0,0 +1,28 @@
-- SQLite schema for User Management
-- User table
CREATE TABLE IF NOT EXISTS user
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
metadata JSON DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Auth Token table
CREATE TABLE IF NOT EXISTS sessions
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
session_id TEXT NOT NULL UNIQUE,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
-- Indexes to improve query performance
CREATE INDEX IF NOT EXISTS idx_user_email ON user (email);
CREATE INDEX IF NOT EXISTS idx_session_id ON sessions (session_id);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id ON sessions (user_id);

View file

@ -0,0 +1,17 @@
package embedded
import (
"io/fs"
"os"
)
type OsFs struct {
}
func (receiver OsFs) Open(name string) (fs.File, error) {
return os.Open(name)
}
func NewOsFs() OsFs {
return OsFs{}
}

View file

@ -0,0 +1,115 @@
package user
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"simpleauth/internal/db"
)
type CreateUserRequest struct {
Email string
Password string
}
type LoginUserRequest struct {
Email string
Password string
}
type CreatedUser struct {
Id string
Email string
}
func Create(ctx *h.RequestContext, request CreateUserRequest) (int64, error) {
if len(request.Password) < 6 {
return 0, errors.New("password must be at least 6 characters long")
}
queries := service.Get[db.Queries](ctx.ServiceLocator())
hashedPassword, err := HashPassword(request.Password)
if err != nil {
return 0, errors.New("something went wrong")
}
id, err := queries.CreateUser(context.Background(), db.CreateUserParams{
Email: request.Email,
Password: hashedPassword,
})
if err != nil {
if err.Error() == "UNIQUE constraint failed: user.email" {
return 0, errors.New("email already exists")
}
return 0, err
}
return id, nil
}
func Login(ctx *h.RequestContext, request LoginUserRequest) (int64, error) {
queries := service.Get[db.Queries](ctx.ServiceLocator())
user, err := queries.GetUserByEmail(context.Background(), request.Email)
if err != nil {
fmt.Printf("error: %s\n", err.Error())
return 0, errors.New("email or password is incorrect")
}
if !PasswordMatches(request.Password, user.Password) {
return 0, errors.New("email or password is incorrect")
}
session, err := CreateSession(ctx, user.ID)
if err != nil {
return 0, errors.New("something went wrong")
}
WriteSessionCookie(ctx, session)
return user.ID, nil
}
func ParseMeta(meta any) map[string]interface{} {
if meta == nil {
return map[string]interface{}{}
}
if m, ok := meta.(string); ok {
var dest map[string]interface{}
json.Unmarshal([]byte(m), &dest)
return dest
}
return meta.(map[string]interface{})
}
func GetMetaKey(meta map[string]interface{}, key string) string {
if val, ok := meta[key]; ok {
return val.(string)
}
return ""
}
func SetMeta(ctx *h.RequestContext, userId int64, meta map[string]interface{}) error {
queries := service.Get[db.Queries](ctx.ServiceLocator())
serialized, _ := json.Marshal(meta)
fmt.Printf("serialized: %s\n", string(serialized))
err := queries.UpdateUserMetadata(context.Background(), db.UpdateUserMetadataParams{
JsonPatch: serialized,
ID: userId,
})
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,17 @@
package user
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/internal/db"
)
func GetUserOrRedirect(ctx *h.RequestContext) (db.User, bool) {
user, err := GetUserFromSession(ctx)
if err != nil {
ctx.Redirect("/login", 302)
return db.User{}, false
}
return user, true
}

View file

@ -0,0 +1,18 @@
package user
import (
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedPassword), nil
}
func PasswordMatches(password string, hashedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
return err == nil
}

View file

@ -0,0 +1,83 @@
package user
import (
"context"
"crypto/rand"
"encoding/hex"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"net/http"
"simpleauth/internal/db"
"time"
)
type CreatedSession struct {
Id string
Expiration time.Time
UserId int64
}
func CreateSession(ctx *h.RequestContext, userId int64) (CreatedSession, error) {
sessionId, err := GenerateSessionID()
if err != nil {
return CreatedSession{}, err
}
// create a session in the database
queries := service.Get[db.Queries](ctx.ServiceLocator())
created := CreatedSession{
Id: sessionId,
Expiration: time.Now().Add(time.Hour * 24),
UserId: userId,
}
err = queries.CreateSession(context.Background(), db.CreateSessionParams{
UserID: created.UserId,
SessionID: created.Id,
ExpiresAt: created.Expiration.Format(time.RFC3339),
})
if err != nil {
return CreatedSession{}, err
}
return created, nil
}
func GetUserFromSession(ctx *h.RequestContext) (db.User, error) {
cookie, err := ctx.Request.Cookie("session_id")
if err != nil {
return db.User{}, err
}
queries := service.Get[db.Queries](ctx.ServiceLocator())
user, err := queries.GetUserByToken(context.Background(), cookie.Value)
if err != nil {
return db.User{}, err
}
return user, nil
}
func WriteSessionCookie(ctx *h.RequestContext, session CreatedSession) {
cookie := http.Cookie{
Name: "session_id",
Value: session.Id,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Expires: session.Expiration,
Path: "/",
}
ctx.SetCookie(&cookie)
}
func GenerateSessionID() (string, error) {
// Create a byte slice for storing the random bytes
bytes := make([]byte, 32) // 32 bytes = 256 bits, which is a secure length
// Read random bytes from crypto/rand
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Encode to hexadecimal to get a string representation
return hex.EncodeToString(bytes), nil
}

View file

@ -0,0 +1,35 @@
package main
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
"simpleauth/__htmgo"
"simpleauth/internal/db"
)
func main() {
locator := service.NewLocator()
service.Set(locator, service.Singleton, func() *db.Queries {
return db.Provide()
})
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
__htmgo.Register(app.Router)
},
})
}

View file

@ -0,0 +1,72 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/internal/db"
"simpleauth/internal/user"
"simpleauth/partials"
"simpleauth/ui"
)
func IndexPage(ctx *h.RequestContext) *h.Page {
u, ok := user.GetUserOrRedirect(ctx)
if !ok {
return nil
}
return h.NewPage(
RootPage(UserProfilePage(u)),
)
}
func UserProfilePage(u db.User) *h.Element {
meta := user.ParseMeta(u.Metadata)
return h.Div(
h.Class("flex flex-col gap-6 items-center pt-10 min-h-screen bg-neutral-100"),
h.H3F(
"User Profile",
h.Class("text-2xl font-bold"),
),
h.Pf("Welcome, %s!", u.Email),
h.Form(
h.Attribute("hx-swap", "none"),
h.PostPartial(partials.UpdateProfile),
h.TriggerChildren(),
h.Class("flex flex-col gap-4 w-full max-w-md p-6 bg-white rounded-md shadow-md"),
ui.Input(ui.InputProps{
Id: "email",
Name: "email",
Label: "Email Address",
Type: "email",
DefaultValue: u.Email,
Children: []h.Ren{
h.Disabled(),
},
}),
ui.Input(ui.InputProps{
Name: "birth-date",
Label: "Birth Date",
DefaultValue: user.GetMetaKey(meta, "birthDate"),
Type: "date",
}),
ui.Input(ui.InputProps{
Name: "favorite-color",
Label: "Favorite Color",
DefaultValue: user.GetMetaKey(meta, "favoriteColor"),
}),
ui.Input(ui.InputProps{
Name: "occupation",
Label: "Occupation",
DefaultValue: user.GetMetaKey(meta, "occupation"),
}),
ui.FormError(""),
ui.SubmitButton("Save Changes"),
),
h.A(
h.Text("Log out"),
h.Href("/logout"),
h.Class("text-blue-400"),
),
)
}

View file

@ -0,0 +1,49 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/partials"
"simpleauth/ui"
)
func Login(ctx *h.RequestContext) *h.Page {
return h.NewPage(
RootPage(
ui.CenteredForm(ui.CenteredFormProps{
Title: "Sign In",
SubmitText: "Sign In",
PostUrl: h.GetPartialPath(partials.LoginUser),
Children: []h.Ren{
ui.Input(ui.InputProps{
Id: "username",
Name: "email",
Label: "Email Address",
Type: "email",
Required: true,
Children: []h.Ren{
h.Attribute("autocomplete", "off"),
h.MaxLength(50),
},
}),
ui.Input(ui.InputProps{
Id: "password",
Name: "password",
Label: "Password",
Type: "password",
Required: true,
Children: []h.Ren{
h.MinLength(6),
},
}),
h.A(
h.Href("/register"),
h.Text("Don't have an account? Register here"),
h.Class("text-blue-500"),
),
},
}),
),
)
}

View file

@ -0,0 +1,23 @@
package pages
import "github.com/maddalax/htmgo/framework/h"
func LogoutPage(ctx *h.RequestContext) *h.Page {
// clear the session cookie
ctx.Response.Header().Set(
"Set-Cookie",
"session_id=; Path=/; Max-Age=0",
)
ctx.Response.Header().Set(
"Location",
"/login",
)
ctx.Response.WriteHeader(
302,
)
return nil
}

View file

@ -0,0 +1,49 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/partials"
"simpleauth/ui"
)
func Register(ctx *h.RequestContext) *h.Page {
return h.NewPage(
RootPage(
ui.CenteredForm(ui.CenteredFormProps{
PostUrl: h.GetPartialPath(partials.RegisterUser),
Title: "Create an Account",
SubmitText: "Register",
Children: []h.Ren{
ui.Input(ui.InputProps{
Id: "username",
Name: "email",
Label: "Email Address",
Type: "email",
Required: true,
Children: []h.Ren{
h.Attribute("autocomplete", "off"),
h.MaxLength(50),
},
}),
ui.Input(ui.InputProps{
Id: "password",
Name: "password",
Label: "Password",
Type: "password",
Required: true,
Children: []h.Ren{
h.MinLength(6),
},
}),
h.A(
h.Href("/login"),
h.Text("Already have an account? Login here"),
h.Class("text-blue-500"),
),
},
}),
),
)
}

View file

@ -0,0 +1,34 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func RootPage(children ...h.Ren) h.Ren {
return h.Html(
h.HxExtensions(
h.BaseExtensions(),
),
h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Link("/public/favicon.ico", "icon"),
h.Link("/public/apple-touch-icon.png", "apple-touch-icon"),
h.Meta("title", "htmgo template"),
h.Meta("charset", "utf-8"),
h.Meta("author", "htmgo"),
h.Meta("description", "this is a template"),
h.Meta("og:title", "htmgo template"),
h.Meta("og:url", "https://htmgo.dev"),
h.Link("canonical", "https://htmgo.dev"),
h.Meta("og:description", "this is a template"),
h.Link("/public/main.css", "stylesheet"),
h.Script("/public/htmgo.js"),
),
h.Body(
h.Div(
h.Class("flex flex-col gap-2 bg-white h-full"),
h.Fragment(children...),
),
),
)
}

View file

@ -0,0 +1,36 @@
package partials
import (
"github.com/maddalax/htmgo/framework/h"
"log/slog"
"simpleauth/internal/user"
"simpleauth/ui"
)
func UpdateProfile(ctx *h.RequestContext) *h.Partial {
if !ctx.IsHttpPost() {
return nil
}
patch := map[string]any{
"birthDate": ctx.FormValue("birth-date"),
"favoriteColor": ctx.FormValue("favorite-color"),
"occupation": ctx.FormValue("occupation"),
}
u, ok := user.GetUserOrRedirect(ctx)
if !ok {
return nil
}
err := user.SetMeta(ctx, u.ID, patch)
if err != nil {
slog.Error("failed to update user profile", slog.String("error", err.Error()))
ctx.Response.WriteHeader(400)
return ui.SwapFormError(ctx, "something went wrong")
}
return h.RedirectPartial("/")
}

View file

@ -0,0 +1,62 @@
package partials
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/internal/user"
"simpleauth/ui"
)
func RegisterUser(ctx *h.RequestContext) *h.Partial {
if !ctx.IsHttpPost() {
return nil
}
payload := user.CreateUserRequest{
Email: ctx.FormValue("email"),
Password: ctx.FormValue("password"),
}
id, err := user.Create(
ctx,
payload,
)
if err != nil {
ctx.Response.WriteHeader(400)
return ui.SwapFormError(ctx, err.Error())
}
session, err := user.CreateSession(ctx, id)
if err != nil {
ctx.Response.WriteHeader(500)
return ui.SwapFormError(ctx, "something went wrong")
}
user.WriteSessionCookie(ctx, session)
return h.RedirectPartial("/")
}
func LoginUser(ctx *h.RequestContext) *h.Partial {
if !ctx.IsHttpPost() {
return nil
}
payload := user.LoginUserRequest{
Email: ctx.FormValue("email"),
Password: ctx.FormValue("password"),
}
_, err := user.Login(
ctx,
payload,
)
if err != nil {
ctx.Response.WriteHeader(400)
return ui.SwapFormError(ctx, err.Error())
}
return h.RedirectPartial("/")
}

View file

@ -0,0 +1,9 @@
version: "2"
sql:
- schema: "internal/db/schema.sql"
queries: "internal/db/queries.sql"
engine: "sqlite"
gen:
go:
package: "db"
out: "internal/db"

View file

@ -0,0 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["**/*.go"],
plugins: [],
};

View file

@ -0,0 +1,41 @@
package ui
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/js"
)
func SubmitButton(submitText string) *h.Element {
buttonClasses := "rounded items-center px-3 py-2 bg-slate-800 text-white w-full text-center"
return h.Div(
h.HxBeforeRequest(
js.RemoveClassOnChildren(".loading", "hidden"),
js.SetClassOnChildren(".submit", "hidden"),
),
h.HxAfterRequest(
js.SetClassOnChildren(".loading", "hidden"),
js.RemoveClassOnChildren(".submit", "hidden"),
),
h.Class("flex gap-2 justify-center"),
h.Button(
h.Class("loading hidden relative text-center", buttonClasses),
spinner(),
h.Disabled(),
h.Text("Submitting..."),
),
h.Button(
h.Type("submit"),
h.Class("submit", buttonClasses),
h.Text(submitText),
),
)
}
func spinner(children ...h.Ren) *h.Element {
return h.Div(
h.Children(children...),
h.Class("absolute left-1 spinner spinner-border animate-spin inline-block w-6 h-6 border-4 rounded-full border-slate-200 border-t-transparent"),
h.Attribute("role", "status"),
)
}

View file

@ -0,0 +1,20 @@
package ui
import "github.com/maddalax/htmgo/framework/h"
func FormError(error string) *h.Element {
return h.Div(
h.Id("form-error"),
h.Text(error),
h.If(
error != "",
h.Class("p-4 bg-rose-400 text-white rounded"),
),
)
}
func SwapFormError(ctx *h.RequestContext, error string) *h.Partial {
return h.SwapPartial(ctx,
FormError(error),
)
}

View file

@ -0,0 +1,81 @@
package ui
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/hx"
)
type InputProps struct {
Id string
Label string
Name string
Type string
DefaultValue string
Placeholder string
Required bool
ValidationPath string
Error string
Children []h.Ren
}
func Input(props InputProps) *h.Element {
validation := h.If(
props.ValidationPath != "",
h.Children(
h.Post(props.ValidationPath, hx.BlurEvent),
h.Attribute("hx-swap", "innerHTML transition:true"),
h.Attribute("hx-target", "next div"),
),
)
if props.Type == "" {
props.Type = "text"
}
input := h.Input(
props.Type,
h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"),
h.If(
props.Name != "",
h.Name(props.Name),
),
h.If(
props.Children != nil,
h.Children(props.Children...),
),
h.If(
props.Required,
h.Required(),
),
h.If(
props.Placeholder != "",
h.Placeholder(props.Placeholder),
),
h.If(
props.DefaultValue != "",
h.Attribute("value", props.DefaultValue),
),
validation,
)
wrapped := h.Div(
h.If(
props.Id != "",
h.Id(props.Id),
),
h.Class("flex flex-col gap-1"),
h.If(
props.Label != "",
h.Label(
h.Text(props.Label),
),
),
input,
h.Div(
h.Id(props.Id+"-error"),
h.Class("text-red-500"),
),
)
return wrapped
}

View file

@ -0,0 +1,36 @@
package ui
import (
"github.com/maddalax/htmgo/framework/h"
)
type CenteredFormProps struct {
Title string
Children []h.Ren
SubmitText string
PostUrl string
}
func CenteredForm(props CenteredFormProps) *h.Element {
return h.Div(
h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"),
h.Div(
h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"),
h.H2F(
props.Title,
h.Class("text-3xl font-bold text-center mb-6"),
),
h.Form(
h.TriggerChildren(),
h.Post(props.PostUrl),
h.Attribute("hx-swap", "none"),
h.Class("flex flex-col gap-4"),
h.Children(props.Children...),
// Error message
FormError(""),
// Submit button at the bottom
SubmitButton(props.SubmitText),
),
),
)
}

View file

@ -5,7 +5,7 @@ go 1.23.0
require ( require (
entgo.io/ent v0.14.1 entgo.io/ent v0.14.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d
github.com/mattn/go-sqlite3 v1.14.23 github.com/mattn/go-sqlite3 v1.14.23
) )

View file

@ -33,8 +33,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d h1:xr5dOwDzFZgZlgL3MmggSS9p+VeC0JawNS6tWBI3XUM=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=

View file

@ -6,7 +6,9 @@ import (
func RootPage(children ...h.Ren) h.Ren { func RootPage(children ...h.Ren) h.Ren {
return h.Html( return h.Html(
h.HxExtension(h.BaseExtensions()), h.HxExtension(
h.BaseExtensions(),
),
h.Head( h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"), h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Meta("title", "htmgo todo mvc"), h.Meta("title", "htmgo todo mvc"),

View file

@ -10,7 +10,10 @@ import (
func TaskListPage(ctx *h.RequestContext) *h.Page { func TaskListPage(ctx *h.RequestContext) *h.Page {
title := h.Div( title := h.Div(
h.H1(h.Class("text-7xl font-extralight text-rose-500 tracking-wide"), h.Text("todos")), h.H1(
h.Class("text-7xl font-extralight text-rose-500 tracking-wide"),
h.Text("todos"),
),
) )
return h.NewPage(base.RootPage( return h.NewPage(base.RootPage(
@ -21,7 +24,9 @@ func TaskListPage(ctx *h.RequestContext) *h.Page {
title, title,
task.Card(ctx), task.Card(ctx),
h.Children( h.Children(
h.Div(h.Text("Double-click to edit a todo")), h.Div(
h.Text("Double-click to edit a todo"),
),
), ),
), ),
), ),

View file

@ -58,7 +58,9 @@ func Input(list []*ent.Task) *h.Element {
h.Name("name"), h.Name("name"),
h.Class("pl-12 text-xl p-4 w-full outline-none focus:outline-2 focus:outline-rose-400"), h.Class("pl-12 text-xl p-4 w-full outline-none focus:outline-2 focus:outline-rose-400"),
h.Placeholder("What needs to be done?"), h.Placeholder("What needs to be done?"),
h.Post(h.GetPartialPath(Create)), h.Post(
h.GetPartialPath(Create),
),
h.HxTrigger(hx.OnEvent(hx.TriggerKeyUpEnter)), h.HxTrigger(hx.OnEvent(hx.TriggerKeyUpEnter)),
), ),
CompleteAllIcon(list), CompleteAllIcon(list),
@ -66,23 +68,34 @@ func Input(list []*ent.Task) *h.Element {
} }
func CompleteAllIcon(list []*ent.Task) *h.Element { func CompleteAllIcon(list []*ent.Task) *h.Element {
notCompletedCount := len(h.Filter(list, func(item *ent.Task) bool { notCompletedCount := len(
return item.CompletedAt == nil h.Filter(list, func(item *ent.Task) bool {
})) return item.CompletedAt == nil
}),
)
return h.Div( return h.Div(
h.ClassX("absolute top-1 left-5 p-2 rotate-90 text-3xl cursor-pointer", map[string]bool{ h.ClassX("absolute top-1 left-5 p-2 rotate-90 text-3xl cursor-pointer", map[string]bool{
"text-slate-400": notCompletedCount > 0, "text-slate-400": notCompletedCount > 0,
}), h.UnsafeRaw("&#x203A;"), }),
h.PostPartialWithQs(CompleteAll, h.NewQs("complete", h.Ternary(notCompletedCount > 0, "true", "false"))), h.UnsafeRaw("&#x203A;"),
h.PostPartialWithQs(
CompleteAll,
h.NewQs(
"complete",
h.Ternary(notCompletedCount > 0, "true", "false"),
),
),
) )
} }
func Footer(list []*ent.Task, activeTab Tab) *h.Element { func Footer(list []*ent.Task, activeTab Tab) *h.Element {
notCompletedCount := len(h.Filter(list, func(item *ent.Task) bool { notCompletedCount := len(
return item.CompletedAt == nil h.Filter(list, func(item *ent.Task) bool {
})) return item.CompletedAt == nil
}),
)
tabs := []Tab{TabAll, TabActive, TabComplete} tabs := []Tab{TabAll, TabActive, TabComplete}
@ -96,7 +109,12 @@ func Footer(list []*ent.Task, activeTab Tab) *h.Element {
h.Class("flex items-center gap-4"), h.Class("flex items-center gap-4"),
h.List(tabs, func(tab Tab, index int) *h.Element { h.List(tabs, func(tab Tab, index int) *h.Element {
return h.P( return h.P(
h.PostOnClick(h.GetPartialPathWithQs(ChangeTab, h.NewQs("tab", tab))), h.PostOnClick(
h.GetPartialPathWithQs(
ChangeTab,
h.NewQs("tab", tab),
),
),
h.ClassX("cursor-pointer px-2 py-1 rounded", map[string]bool{ h.ClassX("cursor-pointer px-2 py-1 rounded", map[string]bool{
"border border-rose-600": activeTab == tab, "border border-rose-600": activeTab == tab,
}), }),
@ -139,12 +157,14 @@ func Task(task *ent.Task, editing bool) *h.Element {
"border border-b-slate-100": !editing, "border border-b-slate-100": !editing,
}), }),
CompleteIcon(task), CompleteIcon(task),
h.IfElse(editing, h.IfElse(
editing,
h.Div( h.Div(
h.Class("flex-1 h-full"), h.Class("flex-1 h-full"),
h.Form( h.Form(
h.Class("h-full"), h.Class("h-full"),
h.Input("text", h.Input(
"text",
h.Name("task"), h.Name("task"),
h.Value(task.ID.String()), h.Value(task.ID.String()),
h.Class("hidden"), h.Class("hidden"),
@ -168,30 +188,43 @@ func Task(task *ent.Task, editing bool) *h.Element {
), ),
), ),
h.P( h.P(
h.GetPartialWithQs(EditNameForm, h.NewQs("id", task.ID.String()), hx.TriggerDblClick), h.GetPartialWithQs(
EditNameForm,
h.NewQs("id", task.ID.String()),
hx.TriggerDblClick,
),
h.ClassX("text-xl break-all text-wrap truncate", map[string]bool{ h.ClassX("text-xl break-all text-wrap truncate", map[string]bool{
"line-through text-slate-400": task.CompletedAt != nil, "line-through text-slate-400": task.CompletedAt != nil,
}), }),
h.Text(task.Name), h.Text(task.Name),
)), ),
),
) )
} }
func CompleteIcon(task *ent.Task) *h.Element { func CompleteIcon(task *ent.Task) *h.Element {
return h.Div( return h.Div(
h.HxTrigger(hx.OnClick()), h.HxTrigger(hx.OnClick()),
h.Post(h.GetPartialPathWithQs(ToggleCompleted, h.NewQs("id", task.ID.String()))), h.Post(
h.GetPartialPathWithQs(
ToggleCompleted,
h.NewQs("id", task.ID.String()),
),
),
h.Class("flex items-center justify-center cursor-pointer"), h.Class("flex items-center justify-center cursor-pointer"),
h.Div( h.Div(
h.ClassX("w-10 h-10 border rounded-full flex items-center justify-center", map[string]bool{ h.ClassX("w-10 h-10 border rounded-full flex items-center justify-center", map[string]bool{
"border-green-500": task.CompletedAt != nil, "border-green-500": task.CompletedAt != nil,
"border-slate-400": task.CompletedAt == nil, "border-slate-400": task.CompletedAt == nil,
}), }),
h.If(task.CompletedAt != nil, h.UnsafeRaw(` h.If(
task.CompletedAt != nil,
h.UnsafeRaw(`
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
</svg> </svg>
`)), `),
),
), ),
) )
} }
@ -199,46 +232,75 @@ func CompleteIcon(task *ent.Task) *h.Element {
func UpdateName(ctx *h.RequestContext) *h.Partial { func UpdateName(ctx *h.RequestContext) *h.Partial {
id, err := uuid.Parse(ctx.FormValue("task")) id, err := uuid.Parse(ctx.FormValue("task"))
if err != nil { if err != nil {
return h.NewPartial(h.Div(h.Text("invalid id"))) return h.NewPartial(
h.Div(
h.Text("invalid id"),
),
)
} }
name := ctx.FormValue("name") name := ctx.FormValue("name")
if name == "" { if name == "" {
return h.NewPartial(h.Div(h.Text("name is required"))) return h.NewPartial(
h.Div(
h.Text("name is required"),
),
)
} }
if len(name) > 150 { if len(name) > 150 {
return h.NewPartial(h.Div(h.Text("task must be less than 150 characters"))) return h.NewPartial(
h.Div(
h.Text("task must be less than 150 characters"),
),
)
} }
service := tasks.NewService(ctx) service := tasks.NewService(ctx)
task, err := service.Get(id) task, err := service.Get(id)
if task == nil { if task == nil {
return h.NewPartial(h.Div(h.Text("task not found"))) return h.NewPartial(
h.Div(
h.Text("task not found"),
),
)
} }
task, err = service.SetName(task.ID, name) task, err = service.SetName(task.ID, name)
if err != nil { if err != nil {
return h.NewPartial(h.Div(h.Text("failed to update"))) return h.NewPartial(
h.Div(
h.Text("failed to update"),
),
)
} }
return h.NewPartial( return h.NewPartial(
h.OobSwap(ctx, Task(task, false))) h.OobSwap(ctx, Task(task, false)),
)
} }
func EditNameForm(ctx *h.RequestContext) *h.Partial { func EditNameForm(ctx *h.RequestContext) *h.Partial {
id, err := uuid.Parse(ctx.QueryParam("id")) id, err := uuid.Parse(ctx.QueryParam("id"))
if err != nil { if err != nil {
return h.NewPartial(h.Div(h.Text("invalid id"))) return h.NewPartial(
h.Div(
h.Text("invalid id"),
),
)
} }
service := tasks.NewService(ctx) service := tasks.NewService(ctx)
task, err := service.Get(id) task, err := service.Get(id)
if task == nil { if task == nil {
return h.NewPartial(h.Div(h.Text("task not found"))) return h.NewPartial(
h.Div(
h.Text("task not found"),
),
)
} }
return h.NewPartial( return h.NewPartial(
@ -249,21 +311,36 @@ func EditNameForm(ctx *h.RequestContext) *h.Partial {
func ToggleCompleted(ctx *h.RequestContext) *h.Partial { func ToggleCompleted(ctx *h.RequestContext) *h.Partial {
id, err := uuid.Parse(ctx.QueryParam("id")) id, err := uuid.Parse(ctx.QueryParam("id"))
if err != nil { if err != nil {
return h.NewPartial(h.Div(h.Text("invalid id"))) return h.NewPartial(
h.Div(
h.Text("invalid id"),
),
)
} }
service := tasks.NewService(ctx) service := tasks.NewService(ctx)
task, err := service.Get(id) task, err := service.Get(id)
if task == nil { if task == nil {
return h.NewPartial(h.Div(h.Text("task not found"))) return h.NewPartial(
h.Div(
h.Text("task not found"),
),
)
} }
task, err = service.SetCompleted(task.ID, h. task, err = service.SetCompleted(
Ternary(task.CompletedAt == nil, true, false)) task.ID,
h.
Ternary(task.CompletedAt == nil, true, false),
)
if err != nil { if err != nil {
return h.NewPartial(h.Div(h.Text("failed to update"))) return h.NewPartial(
h.Div(
h.Text("failed to update"),
),
)
} }
list, _ := service.List() list, _ := service.List()
@ -282,7 +359,9 @@ func CompleteAll(ctx *h.RequestContext) *h.Partial {
list, _ := service.List() list, _ := service.List()
return h.NewPartial(h.OobSwap(ctx, CardBody(list, getActiveTab(ctx)))) return h.NewPartial(
h.OobSwap(ctx, CardBody(list, getActiveTab(ctx))),
)
} }
func ClearCompleted(ctx *h.RequestContext) *h.Partial { func ClearCompleted(ctx *h.RequestContext) *h.Partial {
@ -291,7 +370,9 @@ func ClearCompleted(ctx *h.RequestContext) *h.Partial {
list, _ := service.List() list, _ := service.List()
return h.NewPartial(h.OobSwap(ctx, CardBody(list, getActiveTab(ctx)))) return h.NewPartial(
h.OobSwap(ctx, CardBody(list, getActiveTab(ctx))),
)
} }
func Create(ctx *h.RequestContext) *h.Partial { func Create(ctx *h.RequestContext) *h.Partial {
@ -300,7 +381,9 @@ func Create(ctx *h.RequestContext) *h.Partial {
if len(name) > 150 { if len(name) > 150 {
return h.NewPartial( return h.NewPartial(
h.Div( h.Div(
h.HxOnLoad(js.Alert("Task must be less than 150 characters")), h.HxOnLoad(
js.Alert("Task must be less than 150 characters"),
),
), ),
) )
} }
@ -312,7 +395,9 @@ func Create(ctx *h.RequestContext) *h.Partial {
if list != nil && len(list) >= 100 { if list != nil && len(list) >= 100 {
return h.NewPartial( return h.NewPartial(
h.Div( h.Div(
h.HxOnLoad(js.Alert("There are too many tasks, please complete and clear some.")), h.HxOnLoad(
js.Alert("There are too many tasks, please complete and clear some."),
),
), ),
) )
} }
@ -322,7 +407,11 @@ func Create(ctx *h.RequestContext) *h.Partial {
}) })
if err != nil { if err != nil {
return h.NewPartial(h.Div(h.Text("failed to create"))) return h.NewPartial(
h.Div(
h.Text("failed to create"),
),
)
} }
list, err = service.List() list, err = service.List()
@ -338,8 +427,12 @@ func ChangeTab(ctx *h.RequestContext) *h.Partial {
tab := ctx.QueryParam("tab") tab := ctx.QueryParam("tab")
return h.SwapManyPartialWithHeaders(ctx, return h.SwapManyPartialWithHeaders(
h.PushQsHeader(ctx, h.NewQs("tab", tab)), ctx,
h.PushQsHeader(
ctx,
h.NewQs("tab", tab),
),
List(list, tab), List(list, tab),
Footer(list, tab), Footer(list, tab),
) )

View file

@ -2,7 +2,7 @@ module github.com/maddalax/htmgo/framework-ui
go 1.23.0 go 1.23.0
require github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d require github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d
require ( require (
github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-chi/chi/v5 v5.1.0 // indirect

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d h1:xr5dOwDzFZgZlgL3MmggSS9p+VeC0JawNS6tWBI3XUM=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=

View file

@ -83,3 +83,17 @@ function onUrlChange(newUrl: string) {
} }
}); });
} }
/*
400s should allow swapping by default, as it's useful to show error messages
*/
document.addEventListener('htmx:beforeSwap', function(evt) {
if(evt instanceof CustomEvent) {
// Allow 422 and 400 responses to swap
// We treat these as form validation errors
if (evt.detail.xhr.status === 422 || evt.detail.xhr.status === 400) {
evt.detail.shouldSwap = true;
evt.detail.isError = false;
}
}
});

View file

@ -12,8 +12,10 @@ htmx.defineExtension("mutation-error", {
} }
const status = evt.detail.xhr.status; const status = evt.detail.xhr.status;
if (status >= 400) { if (status >= 400) {
htmx.findAll("[hx-on\\:\\:mutation-error]").forEach((element) => { document.querySelectorAll("*").forEach((element) => {
htmx.trigger(element, "htmx:mutation-error", { status }); if (element.hasAttribute("hx-on::on-mutation-error")) {
htmx.trigger(element, "htmx:on-mutation-error", { status });
}
}); });
} }
} }

View file

@ -5,12 +5,16 @@ import (
"log/slog" "log/slog"
"os" "os"
"path" "path"
"strings"
) )
type ProjectConfig struct { type ProjectConfig struct {
Tailwind bool `yaml:"tailwind"` Tailwind bool `yaml:"tailwind"`
WatchIgnore []string `yaml:"watch_ignore"` WatchIgnore []string `yaml:"watch_ignore"`
WatchFiles []string `yaml:"watch_files"` WatchFiles []string `yaml:"watch_files"`
AutomaticPageRoutingIgnore []string `yaml:"automatic_page_routing_ignore"`
AutomaticPartialRoutingIgnore []string `yaml:"automatic_partial_routing_ignore"`
PublicAssetPath string `yaml:"public_asset_path"`
} }
func DefaultProjectConfig() *ProjectConfig { func DefaultProjectConfig() *ProjectConfig {
@ -22,10 +26,11 @@ func DefaultProjectConfig() *ProjectConfig {
WatchFiles: []string{ WatchFiles: []string{
"**/*.go", "**/*.html", "**/*.css", "**/*.js", "**/*.json", "**/*.yaml", "**/*.yml", "**/*.md", "**/*.go", "**/*.html", "**/*.css", "**/*.js", "**/*.json", "**/*.yaml", "**/*.yml", "**/*.md",
}, },
PublicAssetPath: "/public",
} }
} }
func (cfg *ProjectConfig) EnhanceWithDefaults() *ProjectConfig { func (cfg *ProjectConfig) Enhance() *ProjectConfig {
defaultCfg := DefaultProjectConfig() defaultCfg := DefaultProjectConfig()
if len(cfg.WatchFiles) == 0 { if len(cfg.WatchFiles) == 0 {
cfg.WatchFiles = defaultCfg.WatchFiles cfg.WatchFiles = defaultCfg.WatchFiles
@ -33,9 +38,43 @@ func (cfg *ProjectConfig) EnhanceWithDefaults() *ProjectConfig {
if len(cfg.WatchIgnore) == 0 { if len(cfg.WatchIgnore) == 0 {
cfg.WatchIgnore = defaultCfg.WatchIgnore cfg.WatchIgnore = defaultCfg.WatchIgnore
} }
for i, s := range cfg.AutomaticPartialRoutingIgnore {
parts := strings.Split(s, string(os.PathSeparator))
if len(parts) == 0 {
continue
}
if parts[0] != "partials" {
cfg.AutomaticPartialRoutingIgnore[i] = path.Join("partials", s)
}
}
for i, s := range cfg.AutomaticPageRoutingIgnore {
parts := strings.Split(s, string(os.PathSeparator))
if len(parts) == 0 {
continue
}
if parts[0] != "pages" {
cfg.AutomaticPageRoutingIgnore[i] = path.Join("pages", s)
}
}
if cfg.PublicAssetPath == "" {
cfg.PublicAssetPath = "/public"
}
return cfg return cfg
} }
func Get() *ProjectConfig {
cwd, err := os.Getwd()
if err != nil {
return DefaultProjectConfig()
}
config := FromConfigFile(cwd)
return config
}
func FromConfigFile(workingDir string) *ProjectConfig { func FromConfigFile(workingDir string) *ProjectConfig {
defaultCfg := DefaultProjectConfig() defaultCfg := DefaultProjectConfig()
names := []string{"htmgo.yaml", "htmgo.yml", "_htmgo.yaml", "_htmgo.yml"} names := []string{"htmgo.yaml", "htmgo.yml", "_htmgo.yaml", "_htmgo.yml"}
@ -50,7 +89,7 @@ func FromConfigFile(workingDir string) *ProjectConfig {
slog.Error("Error parsing config file", slog.String("file", filePath), slog.String("error", err.Error())) slog.Error("Error parsing config file", slog.String("file", filePath), slog.String("error", err.Error()))
os.Exit(1) os.Exit(1)
} }
return cfg.EnhanceWithDefaults() return cfg.Enhance()
} }
} }
} }

View file

@ -41,6 +41,53 @@ func TestShouldNotSetTailwindTrue(t *testing.T) {
assert.Equal(t, 8, len(cfg.WatchFiles)) assert.Equal(t, 8, len(cfg.WatchFiles))
} }
func TestShouldPrefixAutomaticPageRoutingIgnore(t *testing.T) {
t.Parallel()
cfg := DefaultProjectConfig()
cfg.AutomaticPageRoutingIgnore = []string{"somefile"}
cfg.Enhance()
assert.Equal(t, []string{"pages/somefile"}, cfg.AutomaticPageRoutingIgnore)
}
func TestShouldPrefixAutomaticPageRoutingIgnore_1(t *testing.T) {
t.Parallel()
cfg := DefaultProjectConfig()
cfg.AutomaticPageRoutingIgnore = []string{"pages/somefile/*"}
cfg.Enhance()
assert.Equal(t, []string{"pages/somefile/*"}, cfg.AutomaticPageRoutingIgnore)
}
func TestShouldPrefixAutomaticPartialRoutingIgnore(t *testing.T) {
t.Parallel()
cfg := DefaultProjectConfig()
cfg.AutomaticPartialRoutingIgnore = []string{"somefile/*"}
cfg.Enhance()
assert.Equal(t, []string{"partials/somefile/*"}, cfg.AutomaticPartialRoutingIgnore)
}
func TestShouldPrefixAutomaticPartialRoutingIgnore_1(t *testing.T) {
t.Parallel()
cfg := DefaultProjectConfig()
cfg.AutomaticPartialRoutingIgnore = []string{"partials/somefile/*"}
cfg.Enhance()
assert.Equal(t, []string{"partials/somefile/*"}, cfg.AutomaticPartialRoutingIgnore)
}
func TestPublicAssetPath(t *testing.T) {
t.Parallel()
cfg := DefaultProjectConfig()
assert.Equal(t, "/public", cfg.PublicAssetPath)
cfg.PublicAssetPath = "/assets"
assert.Equal(t, "/assets", cfg.PublicAssetPath)
}
func TestConfigGet(t *testing.T) {
t.Parallel()
cfg := Get()
assert.Equal(t, "/public", cfg.PublicAssetPath)
}
func writeConfigFile(t *testing.T, content string) string { func writeConfigFile(t *testing.T, content string) string {
temp := os.TempDir() temp := os.TempDir()
os.Mkdir(temp, 0755) os.Mkdir(temp, 0755)

View file

@ -1,18 +1,24 @@
package datastructures package orderedmap
// OrderedMap is a generic data structure that maintains the order of keys.
type OrderedMap[K comparable, V any] struct {
keys []K
values map[K]V
}
type Entry[K comparable, V any] struct { type Entry[K comparable, V any] struct {
Key K Key K
Value V Value V
} }
// Map is a generic data structure that maintains the order of keys.
type Map[K comparable, V any] struct {
keys []K
values map[K]V
}
func (om *Map[K, V]) Each(cb func(key K, value V)) {
for _, key := range om.keys {
cb(key, om.values[key])
}
}
// Entries returns the key-value pairs in the order they were added. // Entries returns the key-value pairs in the order they were added.
func (om *OrderedMap[K, V]) Entries() []Entry[K, V] { func (om *Map[K, V]) Entries() []Entry[K, V] {
entries := make([]Entry[K, V], len(om.keys)) entries := make([]Entry[K, V], len(om.keys))
for i, key := range om.keys { for i, key := range om.keys {
entries[i] = Entry[K, V]{ entries[i] = Entry[K, V]{
@ -23,16 +29,16 @@ func (om *OrderedMap[K, V]) Entries() []Entry[K, V] {
return entries return entries
} }
// NewOrderedMap creates a new OrderedMap. // New creates a new Map.
func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] { func New[K comparable, V any]() *Map[K, V] {
return &OrderedMap[K, V]{ return &Map[K, V]{
keys: []K{}, keys: []K{},
values: make(map[K]V), values: make(map[K]V),
} }
} }
// Set adds or updates a key-value pair in the OrderedMap. // Set adds or updates a key-value pair in the Map.
func (om *OrderedMap[K, V]) Set(key K, value V) { func (om *Map[K, V]) Set(key K, value V) {
// Check if the key already exists // Check if the key already exists
if _, exists := om.values[key]; !exists { if _, exists := om.values[key]; !exists {
om.keys = append(om.keys, key) // Append key to the keys slice if it's a new key om.keys = append(om.keys, key) // Append key to the keys slice if it's a new key
@ -41,18 +47,18 @@ func (om *OrderedMap[K, V]) Set(key K, value V) {
} }
// Get retrieves a value by key. // Get retrieves a value by key.
func (om *OrderedMap[K, V]) Get(key K) (V, bool) { func (om *Map[K, V]) Get(key K) (V, bool) {
value, exists := om.values[key] value, exists := om.values[key]
return value, exists return value, exists
} }
// Keys returns the keys in the order they were added. // Keys returns the keys in the order they were added.
func (om *OrderedMap[K, V]) Keys() []K { func (om *Map[K, V]) Keys() []K {
return om.keys return om.keys
} }
// Values returns the values in the order of their keys. // Values returns the values in the order of their keys.
func (om *OrderedMap[K, V]) Values() []V { func (om *Map[K, V]) Values() []V {
values := make([]V, len(om.keys)) values := make([]V, len(om.keys))
for i, key := range om.keys { for i, key := range om.keys {
values[i] = om.values[key] values[i] = om.values[key]
@ -61,8 +67,8 @@ func (om *OrderedMap[K, V]) Values() []V {
return values return values
} }
// Delete removes a key-value pair from the OrderedMap. // Delete removes a key-value pair from the Map.
func (om *OrderedMap[K, V]) Delete(key K) { func (om *Map[K, V]) Delete(key K) {
if _, exists := om.values[key]; exists { if _, exists := om.values[key]; exists {
// Remove the key from the map // Remove the key from the map
delete(om.values, key) delete(om.values, key)

View file

@ -0,0 +1,63 @@
package orderedmap
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestOrderedMap(t *testing.T) {
t.Parallel()
om := New[string, int]()
alphabet := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"}
for index, letter := range alphabet {
om.Set(letter, index)
}
assert.Equal(t, alphabet, om.Keys())
c, ok := om.Get("c")
assert.True(t, ok)
assert.Equal(t, 2, c)
for i, entry := range om.Entries() {
if i == 5 {
assert.Equal(t, "f", entry.Key)
}
}
om.Delete("c")
value, ok := om.Get("c")
assert.False(t, ok)
assert.Equal(t, 0, value)
}
func TestOrderedMapEach(t *testing.T) {
t.Parallel()
om := New[string, int]()
om.Set("one", 1)
om.Set("two", 2)
om.Set("three", 3)
expected := map[string]int{"one": 1, "two": 2, "three": 3}
actual := make(map[string]int)
om.Each(func(key string, value int) {
actual[key] = value
})
assert.Equal(t, expected, actual)
}
func TestOrderedMapValues(t *testing.T) {
t.Parallel()
om := New[string, int]()
om.Set("first", 10)
om.Set("second", 20)
om.Set("third", 30)
values := om.Values()
expectedValues := []int{10, 20, 30}
assert.Equal(t, expectedValues, values)
}

View file

@ -3,9 +3,6 @@ package h
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/go-chi/chi/v5"
"github.com/maddalax/htmgo/framework/hx"
"github.com/maddalax/htmgo/framework/service"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@ -13,6 +10,10 @@ import (
"runtime" "runtime"
"strings" "strings"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/maddalax/htmgo/framework/hx"
"github.com/maddalax/htmgo/framework/service"
) )
type RequestContext struct { type RequestContext struct {
@ -33,6 +34,37 @@ func GetRequestContext(r *http.Request) *RequestContext {
return r.Context().Value(RequestContextKey).(*RequestContext) return r.Context().Value(RequestContextKey).(*RequestContext)
} }
func (c *RequestContext) SetCookie(cookie *http.Cookie) {
http.SetCookie(c.Response, cookie)
}
func (c *RequestContext) Redirect(path string, code int) {
if code == 0 {
code = http.StatusTemporaryRedirect
}
if code < 300 || code > 399 {
code = http.StatusTemporaryRedirect
}
c.Response.Header().Set("Location", path)
c.Response.WriteHeader(code)
}
func (c *RequestContext) IsHttpPost() bool {
return c.Request.Method == http.MethodPost
}
func (c *RequestContext) IsHttpGet() bool {
return c.Request.Method == http.MethodGet
}
func (c *RequestContext) IsHttpPut() bool {
return c.Request.Method == http.MethodPut
}
func (c *RequestContext) IsHttpDelete() bool {
return c.Request.Method == http.MethodDelete
}
func (c *RequestContext) FormValue(key string) string { func (c *RequestContext) FormValue(key string) string {
return c.Request.FormValue(key) return c.Request.FormValue(key)
} }
@ -91,6 +123,10 @@ func (c *RequestContext) Get(key string) interface{} {
return c.kv[key] return c.kv[key]
} }
// ServiceLocator returns the service locator to register and retrieve services
// Usage:
// service.Set[db.Queries](locator, service.Singleton, db.Provide)
// service.Get[db.Queries](locator)
func (c *RequestContext) ServiceLocator() *service.Locator { func (c *RequestContext) ServiceLocator() *service.Locator {
return c.locator return c.locator
} }
@ -106,6 +142,7 @@ type App struct {
Router *chi.Mux Router *chi.Mux
} }
// Start starts the htmgo server
func Start(opts AppOpts) { func Start(opts AppOpts) {
router := chi.NewRouter() router := chi.NewRouter()
instance := App{ instance := App{
@ -182,10 +219,9 @@ func (app *App) start() {
} }
port := ":3000" port := ":3000"
slog.Info(fmt.Sprintf("Server started on port %s", port)) slog.Info(fmt.Sprintf("Server started at localhost%s", port))
err := http.ListenAndServe(port, app.Router)
if err != nil { if err := http.ListenAndServe(port, app.Router); err != nil {
// If we are in watch mode, just try to kill any processes holding that port // If we are in watch mode, just try to kill any processes holding that port
// and try again // and try again
if IsDevelopment() && IsWatchMode() { if IsDevelopment() && IsWatchMode() {
@ -197,29 +233,42 @@ func (app *App) start() {
cmd := exec.Command("bash", "-c", fmt.Sprintf("kill -9 $(lsof -ti%s)", port)) cmd := exec.Command("bash", "-c", fmt.Sprintf("kill -9 $(lsof -ti%s)", port))
cmd.Run() cmd.Run()
} }
time.Sleep(time.Millisecond * 50) time.Sleep(time.Millisecond * 50)
err = http.ListenAndServe(":3000", app.Router)
if err != nil { // Try to start server again
if err := http.ListenAndServe(port, app.Router); err != nil {
slog.Error("Failed to restart server", "error", err)
panic(err) panic(err)
} }
} else {
panic(err)
} }
panic(err) panic(err)
} }
} }
func writeHtml(w http.ResponseWriter, element Ren) error { func writeHtml(w http.ResponseWriter, element Ren) error {
w.Header().Set("Content-Type", "text/html") if element == nil {
_, err := fmt.Fprint(w, Render(element)) return nil
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, err := fmt.Fprint(w, Render(element, WithDocType()))
return err return err
} }
func HtmlView(w http.ResponseWriter, page *Page) error { func HtmlView(w http.ResponseWriter, page *Page) error {
// if the page is nil, do nothing, this can happen if custom response is written, such as a 302 redirect
if page == nil {
return nil
}
return writeHtml(w, page.Root) return writeHtml(w, page.Root)
} }
func PartialViewWithHeaders(w http.ResponseWriter, headers *Headers, partial *Partial) error { func PartialViewWithHeaders(w http.ResponseWriter, headers *Headers, partial *Partial) error {
if partial == nil {
return nil
}
if partial.Headers != nil { if partial.Headers != nil {
for s, a := range *partial.Headers { for s, a := range *partial.Headers {
w.Header().Set(s, a) w.Header().Set(s, a)
@ -236,6 +285,10 @@ func PartialViewWithHeaders(w http.ResponseWriter, headers *Headers, partial *Pa
} }
func PartialView(w http.ResponseWriter, partial *Partial) error { func PartialView(w http.ResponseWriter, partial *Partial) error {
if partial == nil {
return nil
}
if partial.Headers != nil { if partial.Headers != nil {
for s, a := range *partial.Headers { for s, a := range *partial.Headers {
w.Header().Set(s, a) w.Header().Set(s, a)

View file

@ -1,5 +1,10 @@
package h package h
import (
"github.com/maddalax/htmgo/framework/datastructure/orderedmap"
)
// Unique returns a new slice with only unique items.
func Unique[T any](slice []T, key func(item T) string) []T { func Unique[T any](slice []T, key func(item T) string) []T {
var result []T var result []T
seen := make(map[string]bool) seen := make(map[string]bool)
@ -13,6 +18,45 @@ func Unique[T any](slice []T, key func(item T) string) []T {
return result return result
} }
// Find returns the first item in the slice that matches the predicate.
func Find[T any](slice []T, predicate func(item *T) bool) *T {
for _, v := range slice {
if predicate(&v) {
return &v
}
}
return nil
}
// GroupBy groups the items in the slice by the key returned by the key function.
func GroupBy[T any, K comparable](slice []T, key func(item T) K) map[K][]T {
grouped := make(map[K][]T)
for _, item := range slice {
k := key(item)
items, ok := grouped[k]
if !ok {
items = []T{}
}
grouped[k] = append(items, item)
}
return grouped
}
// GroupByOrdered groups the items in the slice by the key returned by the key function, and returns an Map.
func GroupByOrdered[T any, K comparable](slice []T, key func(item T) K) *orderedmap.Map[K, []T] {
grouped := orderedmap.New[K, []T]()
for _, item := range slice {
k := key(item)
items, ok := grouped.Get(k)
if !ok {
items = []T{}
}
grouped.Set(k, append(items, item))
}
return grouped
}
// Filter returns a new slice with only items that match the predicate.
func Filter[T any](slice []T, predicate func(item T) bool) []T { func Filter[T any](slice []T, predicate func(item T) bool) []T {
var result []T var result []T
for _, v := range slice { for _, v := range slice {
@ -23,6 +67,7 @@ func Filter[T any](slice []T, predicate func(item T) bool) []T {
return result return result
} }
// Map returns a new slice with the results of the mapper function.
func Map[T, U any](slice []T, mapper func(item T) U) []U { func Map[T, U any](slice []T, mapper func(item T) U) []U {
var result []U var result []U
for _, v := range slice { for _, v := range slice {

102
framework/h/array_test.go Normal file
View file

@ -0,0 +1,102 @@
package h
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
)
func TestUnique(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "b", "c", "d", "d", "x"}
unique := Unique(slice, func(item string) string {
return item
})
assert.Equal(t, []string{"a", "b", "c", "d", "x"}, unique)
}
func TestFilter(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "b", "c", "d", "d", "x"}
filtered := Filter(slice, func(item string) bool {
return item == "b"
})
assert.Equal(t, []string{"b", "b"}, filtered)
}
func TestMap(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
mapped := Map(slice, func(item string) string {
return strings.ToUpper(item)
})
assert.Equal(t, []string{"A", "B", "C"}, mapped)
}
func TestGroupBy(t *testing.T) {
t.Parallel()
type Item struct {
Name string
Job string
}
items := []Item{
{Name: "Alice", Job: "Developer"},
{Name: "Bob", Job: "Designer"},
{Name: "Charlie", Job: "Developer"},
{Name: "David", Job: "Designer"},
{Name: "Eve", Job: "Developer"},
{Name: "Frank", Job: "Product Manager"},
}
grouped := GroupBy(items, func(item Item) string {
return item.Job
})
assert.Equal(t, 3, len(grouped))
assert.Equal(t, 3, len(grouped["Developer"]))
assert.Equal(t, 2, len(grouped["Designer"]))
assert.Equal(t, 1, len(grouped["Product Manager"]))
}
func TestGroupByOrdered(t *testing.T) {
t.Parallel()
type Item struct {
Name string
Job string
}
items := []Item{
{Name: "Alice", Job: "Developer"},
{Name: "Bob", Job: "Designer"},
{Name: "Charlie", Job: "Developer"},
{Name: "David", Job: "Designer"},
{Name: "Eve", Job: "Developer"},
{Name: "Frank", Job: "Product Manager"},
}
grouped := GroupByOrdered(items, func(item Item) string {
return item.Job
})
keys := []string{"Developer", "Designer", "Product Manager"}
assert.Equal(t, keys, grouped.Keys())
devs, ok := grouped.Get("Developer")
assert.True(t, ok)
assert.Equal(t, 3, len(devs))
assert.Equal(t, "Alice", devs[0].Name)
assert.Equal(t, "Charlie", devs[1].Name)
assert.Equal(t, "Eve", devs[2].Name)
}
func TestFind(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
found := Find(slice, func(item *string) bool {
return *item == "b"
})
assert.Equal(t, "b", *found)
}

View file

@ -2,6 +2,7 @@ package h
import ( import (
"fmt" "fmt"
"github.com/maddalax/htmgo/framework/datastructure/orderedmap"
"github.com/maddalax/htmgo/framework/hx" "github.com/maddalax/htmgo/framework/hx"
"github.com/maddalax/htmgo/framework/internal/datastructure" "github.com/maddalax/htmgo/framework/internal/datastructure"
"github.com/maddalax/htmgo/framework/internal/util" "github.com/maddalax/htmgo/framework/internal/util"
@ -11,7 +12,7 @@ import (
type AttributeMap = map[string]any type AttributeMap = map[string]any
type AttributeMapOrdered struct { type AttributeMapOrdered struct {
data *datastructure.OrderedMap[string, string] data *orderedmap.Map[string, string]
} }
func (m *AttributeMapOrdered) Set(key string, value any) { func (m *AttributeMapOrdered) Set(key string, value any) {
@ -39,12 +40,12 @@ func (m *AttributeMapOrdered) Each(cb func(key string, value string)) {
}) })
} }
func (m *AttributeMapOrdered) Entries() []datastructure.MapEntry[string, string] { func (m *AttributeMapOrdered) Entries() []orderedmap.Entry[string, string] {
return m.data.Entries() return m.data.Entries()
} }
func NewAttributeMap(pairs ...string) *AttributeMapOrdered { func NewAttributeMap(pairs ...string) *AttributeMapOrdered {
m := datastructure.NewOrderedMap[string, string]() m := orderedmap.New[string, string]()
if len(pairs)%2 == 0 { if len(pairs)%2 == 0 {
for i := 0; i < len(pairs); i++ { for i := 0; i < len(pairs); i++ {
m.Set(pairs[i], pairs[i+1]) m.Set(pairs[i], pairs[i+1])
@ -90,9 +91,7 @@ func Checked() Ren {
} }
func Id(value string) Ren { func Id(value string) Ren {
if strings.HasPrefix(value, "#") { value = strings.TrimPrefix(value, "#")
value = value[1:]
}
return Attribute("id", value) return Attribute("id", value)
} }
@ -121,27 +120,34 @@ func HxIndicator(tag string) *AttributeR {
return Attribute(hx.IndicatorAttr, tag) return Attribute(hx.IndicatorAttr, tag)
} }
// TriggerChildren Adds the hx-extension="trigger-children" to an element
// See https://htmgo.dev/docs#htmx-extensions-trigger-children
func TriggerChildren() *AttributeR { func TriggerChildren() *AttributeR {
return HxExtension("trigger-children") return HxExtension("trigger-children")
} }
// HxTriggerString Adds a hx-trigger to an element based on a string of triggers
func HxTriggerString(triggers ...string) *AttributeR { func HxTriggerString(triggers ...string) *AttributeR {
trigger := hx.NewStringTrigger(strings.Join(triggers, ", ")) trigger := hx.NewStringTrigger(strings.Join(triggers, ", "))
return Attribute(hx.TriggerAttr, trigger.ToString()) return Attribute(hx.TriggerAttr, trigger.ToString())
} }
// HxTrigger Adds a hx-trigger to an element
func HxTrigger(opts ...hx.TriggerEvent) *AttributeR { func HxTrigger(opts ...hx.TriggerEvent) *AttributeR {
return Attribute(hx.TriggerAttr, hx.NewTrigger(opts...).ToString()) return Attribute(hx.TriggerAttr, hx.NewTrigger(opts...).ToString())
} }
// HxTriggerClick Adds a hx-trigger="click" to an element
func HxTriggerClick(opts ...hx.Modifier) *AttributeR { func HxTriggerClick(opts ...hx.Modifier) *AttributeR {
return HxTrigger(hx.OnClick(opts...)) return HxTrigger(hx.OnClick(opts...))
} }
// HxExtension Adds a hx-ext to an element
func HxExtension(value string) *AttributeR { func HxExtension(value string) *AttributeR {
return Attribute(hx.ExtAttr, value) return Attribute(hx.ExtAttr, value)
} }
// HxExtensions Adds multiple hx-ext to an element, separated by commas
func HxExtensions(value ...string) Ren { func HxExtensions(value ...string) Ren {
return Attribute(hx.ExtAttr, strings.Join(value, ",")) return Attribute(hx.ExtAttr, strings.Join(value, ","))
} }
@ -150,6 +156,8 @@ func JoinExtensions(attrs ...*AttributeR) Ren {
return JoinAttributes(", ", attrs...) return JoinAttributes(", ", attrs...)
} }
// JoinAttributes joins multiple attributes into a single attribute string based on a separator
// Example: JoinAttributes(", ", Attribute("hx-extension", "one"), Attribute("hx-extension", "two")) = hx-extension="one,two"
func JoinAttributes(sep string, attrs ...*AttributeR) *AttributeR { func JoinAttributes(sep string, attrs ...*AttributeR) *AttributeR {
values := make([]string, 0, len(attrs)) values := make([]string, 0, len(attrs))
for _, a := range attrs { for _, a := range attrs {
@ -190,10 +198,23 @@ func Hidden() Ren {
return Attribute("style", "display:none") return Attribute("style", "display:none")
} }
func Controls() Ren {
return Attribute("controls", "")
}
func Class(value ...string) *AttributeR { func Class(value ...string) *AttributeR {
return Attribute("class", MergeClasses(value...)) return Attribute("class", MergeClasses(value...))
} }
// ClassF is a helper function to create a class attribute with the given format string and arguments
func ClassF(format string, args ...interface{}) *AttributeR {
atr := fmt.Sprintf(format, args...)
return Attribute("class", atr)
}
// ClassX conditionally renders a class based on a map of class names and boolean values
// value is any non-conditional class name you'd like to add
// m is a map of class names and boolean values
func ClassX(value string, m ClassMap) Ren { func ClassX(value string, m ClassMap) Ren {
builder := strings.Builder{} builder := strings.Builder{}
builder.WriteString(value) builder.WriteString(value)
@ -207,6 +228,7 @@ func ClassX(value string, m ClassMap) Ren {
return Class(builder.String()) return Class(builder.String())
} }
// MergeClasses merges multiple classes into a single class string
func MergeClasses(classes ...string) string { func MergeClasses(classes ...string) string {
if len(classes) == 1 { if len(classes) == 1 {
return classes[0] return classes[0]

View file

@ -0,0 +1,160 @@
package h
import (
"github.com/maddalax/htmgo/framework/hx"
"github.com/stretchr/testify/assert"
"testing"
)
func TestAttributes(t *testing.T) {
tests := []struct {
name string
attribute *AttributeR
expectedKey string
expectedValue string
}{
{"NoSwap", NoSwap(), "hx-swap", "none"},
{"Checked", Checked().(*AttributeR), "checked", ""},
{"Id", Id("myID").(*AttributeR), "id", "myID"},
{"Disabled", Disabled(), "disabled", ""},
{"HxTarget", HxTarget("#myTarget").(*AttributeR), "hx-target", "#myTarget"},
{"Name", Name("myName").(*AttributeR), "name", "myName"},
{"HxConfirm", HxConfirm("Are you sure?").(*AttributeR), "hx-confirm", "Are you sure?"},
{"Class", Class("class1", "class2"), "class", "class1 class2 "},
{"ReadOnly", ReadOnly(), "readonly", ""},
{"Required", Required(), "required", ""},
{"Multiple", Multiple(), "multiple", ""},
{"Selected", Selected(), "selected", ""},
{"MaxLength", MaxLength(10), "maxlength", "10"},
{"MinLength", MinLength(5), "minlength", "5"},
{"Size", Size(3), "size", "3"},
{"Width", Width(100), "width", "100"},
{"Height", Height(200), "height", "200"},
{"Download", Download(true), "download", "true"},
{"Rel", Rel("noopener"), "rel", "noopener"},
{"Pattern", Pattern("[A-Za-z]+"), "pattern", "[A-Za-z]+"},
{"Action", Action("/submit"), "action", "/submit"},
{"Method", Method("POST"), "method", "POST"},
{"Enctype", Enctype("multipart/form-data"), "enctype", "multipart/form-data"},
{"AutoComplete", AutoComplete("on"), "autocomplete", "on"},
{"AutoFocus", AutoFocus(), "autofocus", ""},
{"NoValidate", NoValidate(), "novalidate", ""},
{"Step", Step("0.1"), "step", "0.1"},
{"Max", Max("100"), "max", "100"},
{"Min", Min("0"), "min", "0"},
{"Cols", Cols(30), "cols", "30"},
{"Rows", Rows(10), "rows", "10"},
{"Wrap", Wrap("soft"), "wrap", "soft"},
{"Role", Role("button"), "role", "button"},
{"AriaLabel", AriaLabel("Close Dialog"), "aria-label", "Close Dialog"},
{"AriaHidden", AriaHidden(true), "aria-hidden", "true"},
{"TabIndex", TabIndex(1), "tabindex", "1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expectedKey, tt.attribute.Name)
assert.Equal(t, tt.expectedValue, tt.attribute.Value)
})
}
}
func TestClassF(t *testing.T) {
attribute := ClassF("class-%d", 123)
assert.Equal(t, "class", attribute.Name)
assert.Equal(t, "class-123", attribute.Value)
}
func TestClassX(t *testing.T) {
classMap := ClassMap{"visible": true, "hidden": false}
attribute := ClassX("base", classMap).(*AttributeR)
assert.Equal(t, "class", attribute.Name)
assert.Equal(t, "base visible ", attribute.Value)
}
func TestJoinAttributes(t *testing.T) {
attr1 := Attribute("data-attr", "one")
attr2 := Attribute("data-attr", "two")
joined := JoinAttributes(", ", attr1, attr2)
assert.Equal(t, "data-attr", joined.Name)
assert.Equal(t, "one, two", joined.Value)
}
func TestTarget(t *testing.T) {
attr := Target("_blank")
assert.Equal(t, "target", attr.(*AttributeR).Name)
assert.Equal(t, "_blank", attr.(*AttributeR).Value)
}
func TestD(t *testing.T) {
attr := D("M10 10 H 90 V 90 H 10 Z")
assert.Equal(t, "d", attr.(*AttributeR).Name)
assert.Equal(t, "M10 10 H 90 V 90 H 10 Z", attr.(*AttributeR).Value)
}
func TestHxExtension(t *testing.T) {
attr := HxExtension("trigger-children")
assert.Equal(t, "hx-ext", attr.Name)
assert.Equal(t, "trigger-children", attr.Value)
}
func TestHxExtensions(t *testing.T) {
attr := HxExtensions("foo", "bar")
assert.Equal(t, "hx-ext", attr.(*AttributeR).Name)
assert.Equal(t, "foo,bar", attr.(*AttributeR).Value)
}
func TestHxTrigger(t *testing.T) {
trigger := hx.NewTrigger(hx.OnClick()) // This assumes hx.NewTrigger is a correct call
attr := HxTrigger(hx.OnClick())
assert.Equal(t, "hx-trigger", attr.Name)
assert.Equal(t, trigger.ToString(), attr.Value)
}
func TestHxTriggerClick(t *testing.T) {
attr := HxTriggerClick() // Assuming no options for simplicity
assert.Equal(t, "hx-trigger", attr.Name)
assert.Equal(t, "click", attr.Value)
}
func TestTriggerChildren(t *testing.T) {
attr := TriggerChildren()
assert.Equal(t, "hx-ext", attr.Name)
assert.Equal(t, "trigger-children", attr.Value)
}
func TestHxInclude(t *testing.T) {
attr := HxInclude(".include-selector")
assert.Equal(t, "hx-include", attr.(*AttributeR).Name)
assert.Equal(t, ".include-selector", attr.(*AttributeR).Value)
}
func TestHxIndicator(t *testing.T) {
attr := HxIndicator("#my-indicator")
assert.Equal(t, "hx-indicator", attr.Name)
assert.Equal(t, "#my-indicator", attr.Value)
}
func TestHidden(t *testing.T) {
attr := Hidden()
assert.Equal(t, "style", attr.(*AttributeR).Name)
assert.Equal(t, "display:none", attr.(*AttributeR).Value)
}
func TestControls(t *testing.T) {
attr := Controls()
assert.Equal(t, "controls", attr.(*AttributeR).Name)
assert.Equal(t, "", attr.(*AttributeR).Value)
}
func TestPlaceholder(t *testing.T) {
attr := Placeholder("Enter text")
assert.Equal(t, "placeholder", attr.(*AttributeR).Name)
assert.Equal(t, "Enter text", attr.(*AttributeR).Value)
}
func TestBoost(t *testing.T) {
attr := Boost()
assert.Equal(t, "hx-boost", attr.(*AttributeR).Name)
assert.Equal(t, "true", attr.(*AttributeR).Value)
}

View file

@ -23,6 +23,10 @@ func NewPage(root Ren) *Page {
} }
} }
func EmptyPage() *Page {
return NewPage(Fragment())
}
func NewPageWithHttpMethod(httpMethod string, root *Element) *Page { func NewPageWithHttpMethod(httpMethod string, root *Element) *Page {
return &Page{ return &Page{
HttpMethod: httpMethod, HttpMethod: httpMethod,
@ -67,8 +71,12 @@ func SwapPartial(ctx *RequestContext, swap *Element) *Partial {
SwapMany(ctx, swap)) SwapMany(ctx, swap))
} }
func IsEmptyPartial(partial *Partial) bool {
return partial.Root.tag == "" && len(partial.Root.children) == 0
}
func EmptyPartial() *Partial { func EmptyPartial() *Partial {
return NewPartial(Fragment()) return NewPartial(Empty())
} }
func SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial { func SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial {

141
framework/h/base_test.go Normal file
View file

@ -0,0 +1,141 @@
package h
import (
"github.com/maddalax/htmgo/framework/hx"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewPage(t *testing.T) {
root := Div()
page := NewPage(root)
assert.Equal(t, http.MethodGet, page.HttpMethod)
assert.Equal(t, root, page.Root)
}
func TestEmptyPage(t *testing.T) {
page := EmptyPage()
assert.Equal(t, http.MethodGet, page.HttpMethod)
assert.Equal(t, Empty(), page.Root)
}
func TestNewPageWithHttpMethod(t *testing.T) {
root := Div()
page := NewPageWithHttpMethod(http.MethodPost, root)
assert.Equal(t, http.MethodPost, page.HttpMethod)
assert.Equal(t, root, page.Root)
}
func TestNewPartial(t *testing.T) {
root := Div()
partial := NewPartial(root)
assert.Nil(t, partial.Headers)
assert.Equal(t, root, partial.Root)
}
func TestNewPartialWithHeaders(t *testing.T) {
root := Div()
headers := NewHeaders("Content-Type", "application/json")
partial := NewPartialWithHeaders(headers, root)
assert.Equal(t, headers, partial.Headers)
assert.Equal(t, root, partial.Root)
}
func TestSwapManyPartialWithHeaders(t *testing.T) {
ctx := &RequestContext{isHxRequest: true}
headers := NewHeaders("HX-Trigger", "reload")
elements := []*Element{Div(), Span()}
partial := SwapManyPartialWithHeaders(ctx, headers, elements...)
assert.Equal(t, headers, partial.Headers)
assert.Equal(t, SwapMany(ctx, elements...), partial.Root)
}
func TestRedirectPartial(t *testing.T) {
partial := RedirectPartial("/new-path")
headers := NewHeaders("HX-Redirect", "/new-path")
assert.Equal(t, headers, partial.Headers)
assert.Equal(t, Empty(), partial.Root)
}
func TestRedirectPartialWithHeaders(t *testing.T) {
extraHeaders := NewHeaders("X-Custom", "value")
partial := RedirectPartialWithHeaders("/redirect-path", extraHeaders)
expectedHeaders := NewHeaders("HX-Redirect", "/redirect-path", "X-Custom", "value")
assert.Equal(t, expectedHeaders, partial.Headers)
assert.Equal(t, Empty(), partial.Root)
}
func TestIsEmptyPartial(t *testing.T) {
emptyPartial := EmptyPartial()
nonEmptyPartial := NewPartial(Div())
assert.True(t, IsEmptyPartial(emptyPartial))
assert.False(t, IsEmptyPartial(nonEmptyPartial))
}
func TestGetPartialPath(t *testing.T) {
partial := func(ctx *RequestContext) *Partial {
return &Partial{}
}
path := GetPartialPath(partial)
expectedSegment := "github.com/maddalax/htmgo/framework/h.TestGetPartialPath.func1"
assert.Contains(t, path, expectedSegment)
}
func TestGetPartialPathWithQs(t *testing.T) {
partial := func(ctx *RequestContext) *Partial {
return &Partial{}
}
qs := NewQs("param1", "value1", "param2", "value2")
pathWithQs := GetPartialPathWithQs(partial, qs)
assert.Contains(t, pathWithQs, "param1=value1&param2=value2")
}
func TestSwapManyPartial(t *testing.T) {
ctx := &RequestContext{isHxRequest: true}
element1 := Div()
element2 := Span()
partial := SwapManyPartial(ctx, element1, element2)
// Ensuring the elements have been marked for swap
assert.Equal(t, 1, len(element1.children))
assert.Equal(t, 1, len(element2.children))
assert.Equal(t, Attribute(hx.SwapOobAttr, hx.SwapTypeTrue), element1.children[0])
assert.Equal(t, Attribute(hx.SwapOobAttr, hx.SwapTypeTrue), element2.children[0])
// Test with non-HX request context
ctx.isHxRequest = false
partial = SwapManyPartial(ctx, element1, element2)
assert.True(t, IsEmptyPartial(partial))
}
func TestSwapPartial(t *testing.T) {
ctx := &RequestContext{isHxRequest: true}
element := Div()
partial := SwapPartial(ctx, element)
// Ensuring the element has been marked for swap
assert.Equal(t, 1, len(element.children))
assert.Equal(t, Attribute(hx.SwapOobAttr, hx.SwapTypeTrue), element.children[0])
// Test with non-HX request context
ctx.isHxRequest = false
partial = SwapPartial(ctx, element)
assert.True(t, IsEmptyPartial(partial))
}

View file

@ -49,6 +49,9 @@ func startExpiredCacheCleaner(node *CachedNode) {
}() }()
} }
// Cached caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unqiue identifier.
func Cached(duration time.Duration, cb GetElementFunc) func() *Element { func Cached(duration time.Duration, cb GetElementFunc) func() *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -64,6 +67,8 @@ func Cached(duration time.Duration, cb GetElementFunc) func() *Element {
} }
} }
// CachedPerKey caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKey[K comparable](duration time.Duration, cb GetElementFuncWithKey[K]) func() *Element { func CachedPerKey[K comparable](duration time.Duration, cb GetElementFuncWithKey[K]) func() *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -94,6 +99,8 @@ type ByKeyEntry struct {
parent *Element parent *Element
} }
// CachedPerKeyT caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFuncTWithKey[K, T]) func(T) *Element { func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFuncTWithKey[K, T]) func(T) *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -118,6 +125,8 @@ func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFun
} }
} }
// CachedPerKeyT2 caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetElementFuncT2WithKey[K, T, T2]) func(T, T2) *Element { func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetElementFuncT2WithKey[K, T, T2]) func(T, T2) *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -142,6 +151,8 @@ func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetE
} }
} }
// CachedPerKeyT3 caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKeyT3[K comparable, T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3WithKey[K, T, T2, T3]) func(T, T2, T3) *Element { func CachedPerKeyT3[K comparable, T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3WithKey[K, T, T2, T3]) func(T, T2, T3) *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -166,6 +177,8 @@ func CachedPerKeyT3[K comparable, T any, T2 any, T3 any](duration time.Duration,
} }
} }
// CachedPerKeyT4 caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKeyT4[K comparable, T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4WithKey[K, T, T2, T3, T4]) func(T, T2, T3, T4) *Element { func CachedPerKeyT4[K comparable, T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4WithKey[K, T, T2, T3, T4]) func(T, T2, T3, T4) *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -190,6 +203,9 @@ func CachedPerKeyT4[K comparable, T any, T2 any, T3 any, T4 any](duration time.D
} }
} }
// CachedT caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unqiue identifier.
func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Element { func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -208,6 +224,9 @@ func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Elem
} }
} }
// CachedT2 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unqiue identifier.
func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2]) func(T, T2) *Element { func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2]) func(T, T2) *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -225,6 +244,9 @@ func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2])
} }
} }
// CachedT3 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unqiue identifier.
func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3[T, T2, T3]) func(T, T2, T3) *Element { func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3[T, T2, T3]) func(T, T2, T3) *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -242,6 +264,9 @@ func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3
} }
} }
// CachedT4 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unqiue identifier.
func CachedT4[T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4[T, T2, T3, T4]) func(T, T2, T3, T4) *Element { func CachedT4[T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4[T, T2, T3, T4]) func(T, T2, T3, T4) *Element {
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
@ -259,6 +284,7 @@ func CachedT4[T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetEleme
} }
} }
// ClearCache clears the cached HTML of the element. This is called automatically by the framework.
func (c *CachedNode) ClearCache() { func (c *CachedNode) ClearCache() {
c.html = "" c.html = ""
if c.byKeyCache != nil { if c.byKeyCache != nil {
@ -273,11 +299,12 @@ func (c *CachedNode) ClearCache() {
} }
} }
// ClearExpired clears all expired cached HTML of the element. This is called automatically by the framework.
func (c *CachedNode) ClearExpired() { func (c *CachedNode) ClearExpired() {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
deletedCount := 0 deletedCount := 0
if c.isByKey == true { if c.isByKey {
if c.byKeyCache != nil && c.byKeyExpiration != nil { if c.byKeyCache != nil && c.byKeyExpiration != nil {
for key := range c.byKeyCache { for key := range c.byKeyCache {
expir, ok := c.byKeyExpiration[key] expir, ok := c.byKeyExpiration[key]
@ -303,7 +330,7 @@ func (c *CachedNode) ClearExpired() {
} }
func (c *CachedNode) Render(ctx *RenderContext) { func (c *CachedNode) Render(ctx *RenderContext) {
if c.isByKey == true { if c.isByKey {
panic("CachedPerKey should not be rendered directly") panic("CachedPerKey should not be rendered directly")
} else { } else {
c.mutex.Lock() c.mutex.Lock()

View file

@ -55,10 +55,12 @@ var re = regexp.MustCompile(`\s+`)
func compareIgnoreSpaces(t *testing.T, actual, expected string) { func compareIgnoreSpaces(t *testing.T, actual, expected string) {
expected = strings.ReplaceAll(expected, "\n", "") expected = strings.ReplaceAll(expected, "\n", "")
expected = strings.ReplaceAll(expected, "\t", "") expected = strings.ReplaceAll(expected, "\t", "")
expected = re.ReplaceAllString(expected, " ")
actual = strings.ReplaceAll(actual, "\n", "") actual = strings.ReplaceAll(actual, "\n", "")
actual = strings.ReplaceAll(actual, "\t", "") actual = strings.ReplaceAll(actual, "\t", "")
actual = re.ReplaceAllString(actual, " ") actual = re.ReplaceAllString(actual, " ")
spaceRegex := regexp.MustCompile(`\s+`)
actual = strings.TrimSpace(spaceRegex.ReplaceAllString(actual, ""))
expected = strings.TrimSpace(spaceRegex.ReplaceAllString(expected, ""))
assert.Equal(t, expected, actual) assert.Equal(t, expected, actual)
} }
@ -75,11 +77,11 @@ func TestJsEval(t *testing.T) {
} }
compareIgnoreSpaces(t, renderJs(t, EvalJsOnParent("element.style.display = 'none'")), ` compareIgnoreSpaces(t, renderJs(t, EvalJsOnParent("element.style.display = 'none'")), `
if(!self.parentElement) { return; } let element = self.parentElement; element.style.display = 'none' if(self.parentElement) { let element = self.parentElement; element.style.display = 'none' }
`) `)
compareIgnoreSpaces(t, renderJs(t, EvalJsOnSibling("button", "element.style.display = 'none'")), ` compareIgnoreSpaces(t, renderJs(t, EvalJsOnSibling("button", "element.style.display = 'none'")), `
if(!self.parentElement) { return; }let siblings = self.parentElement.querySelectorAll('button');siblings.forEach(function(element) {element.style.display = 'none'}); if(self.parentElement) { let siblings = self.parentElement.querySelectorAll('button');siblings.forEach(function(element) {element.style.display = 'none'}); }
`) `)
} }
@ -145,13 +147,13 @@ func TestToggleClassOnElement(t *testing.T) {
func TestSetClassOnParent(t *testing.T) { func TestSetClassOnParent(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, SetClassOnParent("active")), ` compareIgnoreSpaces(t, renderJs(t, SetClassOnParent("active")), `
if(!self.parentElement) { return; } let element = self.parentElement; element.classList.add('active') if(self.parentElement) { let element = self.parentElement; element.classList.add('active') }
`) `)
} }
func TestRemoveClassOnParent(t *testing.T) { func TestRemoveClassOnParent(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, RemoveClassOnParent("active")), ` compareIgnoreSpaces(t, renderJs(t, RemoveClassOnParent("active")), `
if(!self.parentElement) { return; } let element = self.parentElement; element.classList.remove('active') if(self.parentElement) { let element = self.parentElement; element.classList.remove('active') }
`) `)
} }
@ -174,20 +176,28 @@ func TestRemoveClassOnChildren(t *testing.T) {
} }
func TestSetClassOnSibling(t *testing.T) { func TestSetClassOnSibling(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, SetClassOnSibling("button", "selected")), ` compareIgnoreSpaces(t, renderJs(t, SetClassOnSibling("button", "selected")),
if(!self.parentElement) { return; }let siblings = self.parentElement.querySelectorAll('button'); // language=JavaScript
siblings.forEach(function(element) { `
element.classList.add('selected') if(self.parentElement) {
}); let siblings = self.parentElement.querySelectorAll('button');
siblings.forEach(function(element) {
element.classList.add('selected')
});
}
`) `)
} }
func TestRemoveClassOnSibling(t *testing.T) { func TestRemoveClassOnSibling(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, RemoveClassOnSibling("button", "selected")), ` compareIgnoreSpaces(t, renderJs(t, RemoveClassOnSibling("button", "selected")),
if(!self.parentElement) { return; }let siblings = self.parentElement.querySelectorAll('button'); // language=JavaScript
siblings.forEach(function(element) { `
element.classList.remove('selected') if(self.parentElement) {
}); let siblings = self.parentElement.querySelectorAll('button');
siblings.forEach(function(element) {
element.classList.remove('selected')
});
}
`) `)
} }
@ -226,3 +236,163 @@ func TestInjectScriptIfNotExist(t *testing.T) {
} }
`) `)
} }
func TestEvalCommands(t *testing.T) {
t.Parallel()
div := Div(Id("test"))
result := Render(EvalCommands(div,
SetText("hello"),
EvalJs(`
alert('test')
`),
SetClassOnParent("myclass"),
SetClassOnSibling("div", "myclass"),
))
evalId := ""
for _, child := range div.children {
switch child.(type) {
case *AttributeR:
attr := child.(*AttributeR)
if attr.Name == "data-eval-commands-id" {
evalId = attr.Value
break
}
}
}
//language=JavaScript
compareIgnoreSpaces(t, result, fmt.Sprintf(`
let element = document.querySelector("[data-eval-commands-id='%s']");
if(!element) {return;}
self = element;
self.innerText = 'hello'
alert('test')
if(self.parentElement) {
element = self.parentElement;
element.classList.add('myclass')
}
if(self.parentElement) {
let siblings = self.parentElement.querySelectorAll('div');
siblings.forEach(function(element) {
element.classList.add('myclass')
});
}
`, evalId))
}
func TestToggleText(t *testing.T) {
t.Parallel()
result := Render(ToggleText("hello", "world"))
//language=JavaScript
compareIgnoreSpaces(t, result, fmt.Sprintf(`
if(self.innerText === "hello") {
self.innerText = "world";
} else {
self.innerText = "hello";
}
`))
}
func TestToggleTextOnSibling(t *testing.T) {
t.Parallel()
result := Render(ToggleTextOnSibling("div", "hello", "world"))
//language=JavaScript
compareIgnoreSpaces(t, result, fmt.Sprintf(`
if(self.parentElement) {
let siblings = self.parentElement.querySelectorAll('div');
siblings.forEach(function(element){
if(element.innerText === "hello"){
element.innerText= "world";
} else {
element.innerText= "hello";
}
});
}
`))
}
func TestToggleTextOnChildren(t *testing.T) {
t.Parallel()
result := Render(ToggleTextOnChildren("div", "hello", "world"))
//language=JavaScript
compareIgnoreSpaces(t, result, fmt.Sprintf(`
let children = self.querySelectorAll('div');
children.forEach(function(element) {
if(element.innerText === "hello") {
element.innerText = "world";
} else {
element.innerText = "hello";
}
});
`))
}
func TestToggleTextOnParent(t *testing.T) {
t.Parallel()
result := Render(ToggleTextOnParent("hello", "world"))
//language=JavaScript
compareIgnoreSpaces(t, result, fmt.Sprintf(`
if(self.parentElement) {
let element = self.parentElement;
if(element.innerText === "hello") {
element.innerText = "world";
} else {
element.innerText = "hello";
}
}
`))
}
func TestToggleClassOnChildren(t *testing.T) {
t.Parallel()
result := Render(ToggleClassOnChildren("div", "hidden"))
//language=JavaScript
compareIgnoreSpaces(t, result, fmt.Sprintf(`
let children = self.querySelectorAll('div');
children.forEach(function(element) {
element.classList.toggle('hidden')
});
`))
}
func TestToggleClassOnParent(t *testing.T) {
t.Parallel()
result := Render(ToggleClassOnParent("hidden"))
//language=JavaScript
compareIgnoreSpaces(t, result, fmt.Sprintf(`
if(self.parentElement) {
let element = self.parentElement;
element.classList.toggle('hidden')
}
`))
}
func TestToggleClassOnSibling(t *testing.T) {
t.Parallel()
result := Render(ToggleClassOnSibling("div", "hidden"))
//language=JavaScript
compareIgnoreSpaces(t, result, fmt.Sprintf(`
if(self.parentElement) {
let siblings = self.parentElement.querySelectorAll('div');
siblings.forEach(function(element) {
element.classList.toggle('hidden')
});
}
`))
}
func TestPreventDefault(t *testing.T) {
t.Parallel()
compareIgnoreSpaces(t, renderJs(t, PreventDefault()), "event.preventDefault();")
}
func TestConsoleLog(t *testing.T) {
t.Parallel()
compareIgnoreSpaces(t, renderJs(t, ConsoleLog("Log Message")), "console.log('Log Message');")
}
func TestSetValue(t *testing.T) {
t.Parallel()
compareIgnoreSpaces(t, renderJs(t, SetValue("New Value")), "this.value = 'New Value';")
}

View file

@ -1,5 +1,6 @@
package h package h
// If returns the node if the condition is true, otherwise returns an empty element
func If(condition bool, node Ren) Ren { func If(condition bool, node Ren) Ren {
if condition { if condition {
return node return node
@ -8,10 +9,12 @@ func If(condition bool, node Ren) Ren {
} }
} }
// Ternary returns the first argument if the second argument is true, otherwise returns the third argument
func Ternary[T any](value bool, a T, b T) T { func Ternary[T any](value bool, a T, b T) T {
return IfElse(value, a, b) return IfElse(value, a, b)
} }
// ElementIf returns the element if the condition is true, otherwise returns an empty element
func ElementIf(condition bool, element *Element) *Element { func ElementIf(condition bool, element *Element) *Element {
if condition { if condition {
return element return element
@ -20,6 +23,7 @@ func ElementIf(condition bool, element *Element) *Element {
} }
} }
// IfElseE returns element if condition is true, otherwise returns element2
func IfElseE(condition bool, element *Element, element2 *Element) *Element { func IfElseE(condition bool, element *Element, element2 *Element) *Element {
if condition { if condition {
return element return element
@ -28,6 +32,7 @@ func IfElseE(condition bool, element *Element, element2 *Element) *Element {
} }
} }
// IfElse returns node if condition is true, otherwise returns node2
func IfElse[T any](condition bool, node T, node2 T) T { func IfElse[T any](condition bool, node T, node2 T) T {
if condition { if condition {
return node return node
@ -36,6 +41,10 @@ func IfElse[T any](condition bool, node T, node2 T) T {
} }
} }
// IfElseLazy returns node if condition is true, otherwise returns the result of cb2
// This is useful if you want to lazily evaluate a node based on a condition
// For example, If you are rendering a component that requires specific data,
// you can use this to only load the component if the data is available
func IfElseLazy[T any](condition bool, cb1 func() T, cb2 func() T) T { func IfElseLazy[T any](condition bool, cb1 func() T, cb2 func() T) T {
if condition { if condition {
return cb1() return cb1()
@ -44,6 +53,7 @@ func IfElseLazy[T any](condition bool, cb1 func() T, cb2 func() T) T {
} }
} }
// IfHtmxRequest returns the node if the request is an htmx request, otherwise returns an empty element
func IfHtmxRequest(ctx *RequestContext, node Ren) Ren { func IfHtmxRequest(ctx *RequestContext, node Ren) Ren {
if ctx.isHxRequest { if ctx.isHxRequest {
return node return node
@ -51,6 +61,7 @@ func IfHtmxRequest(ctx *RequestContext, node Ren) Ren {
return Empty() return Empty()
} }
// ClassIf returns the class attribute if the condition is true, otherwise returns an empty element
func ClassIf(condition bool, value string) Ren { func ClassIf(condition bool, value string) Ren {
if condition { if condition {
return Class(value) return Class(value)
@ -58,6 +69,7 @@ func ClassIf(condition bool, value string) Ren {
return Empty() return Empty()
} }
// AttributeIf returns the attribute if the condition is true, otherwise returns an empty element
func AttributeIf(condition bool, name string, value string) Ren { func AttributeIf(condition bool, name string, value string) Ren {
if condition { if condition {
return Attribute(name, value) return Attribute(name, value)

View file

@ -0,0 +1,92 @@
package h
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestIf(t *testing.T) {
t.Parallel()
result := If(true, Pf("hello"))
assert.Equal(t, "<p>hello</p>", Render(result))
result2 := If(false, Pf("hello"))
assert.Equal(t, "", Render(result2)) // Expect an empty element
}
func TestIfElse(t *testing.T) {
t.Parallel()
result := IfElse(true, Pf("hello"), Pf("world"))
assert.Equal(t, "<p>hello</p>", Render(result))
result2 := IfElse(false, Pf("hello"), Pf("world"))
assert.Equal(t, "<p>world</p>", Render(result2))
}
func TestTernary(t *testing.T) {
t.Parallel()
result := Ternary(true, Pf("hello"), Pf("world"))
assert.Equal(t, "<p>hello</p>", Render(result))
result2 := Ternary(false, Pf("hello"), Pf("world"))
assert.Equal(t, "<p>world</p>", Render(result2))
}
func TestIfElseLazy(t *testing.T) {
t.Parallel()
result := IfElseLazy(true, func() *Element { return Pf("hello") }, func() *Element { return Pf("world") })
assert.Equal(t, "<p>hello</p>", Render(result))
result2 := IfElseLazy(false, func() *Element { return Pf("hello") }, func() *Element { return Pf("world") })
assert.Equal(t, "<p>world</p>", Render(result2))
}
func TestElementIf(t *testing.T) {
t.Parallel()
element := Pf("hello")
result := ElementIf(true, element)
assert.Equal(t, "<p>hello</p>", Render(result))
result2 := ElementIf(false, element)
assert.Equal(t, "", Render(result2)) // Expect an empty element
}
func TestIfElseE(t *testing.T) {
t.Parallel()
element1 := Pf("hello")
element2 := Pf("world")
result := IfElseE(true, element1, element2)
assert.Equal(t, "<p>hello</p>", Render(result))
result2 := IfElseE(false, element1, element2)
assert.Equal(t, "<p>world</p>", Render(result2))
}
func TestIfHtmxRequest(t *testing.T) {
t.Parallel()
ctx := &RequestContext{isHxRequest: true}
result := IfHtmxRequest(ctx, Pf("hello"))
assert.Equal(t, "<p>hello</p>", Render(result))
ctx2 := &RequestContext{isHxRequest: false}
result2 := IfHtmxRequest(ctx2, Pf("hello"))
assert.Equal(t, "", Render(result2)) // Expect an empty element
}
func TestClassIf(t *testing.T) {
t.Parallel()
result := ClassIf(true, "my-class")
assert.Equal(t, ` class="my-class"`, Render(result))
result2 := ClassIf(false, "my-class")
assert.Equal(t, "", Render(result2)) // Expect an empty element
}
func TestAttributeIf(t *testing.T) {
t.Parallel()
result := AttributeIf(true, "data-test", "value")
assert.Equal(t, ` data-test="value"`, Render(result))
result2 := AttributeIf(false, "data-test", "value")
assert.Equal(t, "", Render(result2)) // Expect an empty element
}

View file

@ -0,0 +1,22 @@
package h
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBaseExtensions(t *testing.T) {
// Test when not in development
os.Unsetenv("ENV")
result := BaseExtensions()
expected := "path-deps, response-targets, mutation-error, htmgo, sse"
assert.Equal(t, expected, result)
// Test when in development
os.Setenv("ENV", "development")
result = BaseExtensions()
expected = "path-deps, response-targets, mutation-error, htmgo, sse, livereload"
assert.Equal(t, expected, result)
}

Some files were not shown because too many files have changed in this diff Show more