Merge remote-tracking branch 'origin/master' into ws-testing
# Conflicts: # framework/assets/dist/htmgo.js # framework/h/attribute.go
52
.github/workflows/release-auth-example.yml
vendored
Normal 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
|
||||
3
.github/workflows/release-chat-example.yml
vendored
|
|
@ -5,9 +5,6 @@ on:
|
|||
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||
types:
|
||||
- completed
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||
push:
|
||||
branches:
|
||||
|
|
|
|||
7
.github/workflows/run-framework-tests.yml
vendored
|
|
@ -25,4 +25,9 @@ jobs:
|
|||
run: cd ./framework && go mod download
|
||||
|
||||
- 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 }}
|
||||
|
|
|
|||
3
.github/workflows/update-framework-dep.yml
vendored
|
|
@ -6,7 +6,8 @@ on:
|
|||
branches:
|
||||
- master # Trigger on pushes to master
|
||||
paths:
|
||||
- 'framework/**' # Trigger only if files in this directory change
|
||||
- 'framework/**'
|
||||
- 'tools/html-to-htmgo/**'
|
||||
|
||||
jobs:
|
||||
update-htmgo-dep:
|
||||
|
|
|
|||
11
README.md
|
|
@ -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**
|
||||
|
||||
### build simple and scalable systems with go + htmx
|
||||
|
|
@ -8,8 +5,13 @@
|
|||
-------
|
||||
[](https://goreportcard.com/report/github.com/maddalax/htmgo)
|
||||

|
||||
[](https://htmgo.dev/docs)
|
||||
[](https://codecov.io/github/maddalax/htmgo)
|
||||
[](https://htmgo.dev/discord)
|
||||
|
||||
|
||||
<sup>looking for a python version? check out: https://fastht.ml</sup>
|
||||
|
||||
**introduction:**
|
||||
|
||||
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)
|
||||
3. automatic page and partial registration based on file path
|
||||
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
|
||||
6. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
|
||||
5. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
|
||||
|
||||
**get started:**
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,19 @@ module github.com/maddalax/htmgo/cli/htmgo
|
|||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/dave/jennifer v1.7.1
|
||||
github.com/fsnotify/fsnotify v1.7.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/net v0.29.0
|
||||
golang.org/x/sys v0.25.0
|
||||
golang.org/x/sys v0.26.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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,34 @@
|
|||
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/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
|
||||
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
|
||||
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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
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/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/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
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/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=
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ package dirutil
|
|||
import (
|
||||
"fmt"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func matchesAny(patterns []string, path string) bool {
|
||||
for _, pattern := range patterns {
|
||||
matched, err := doublestar.Match(pattern, path)
|
||||
matched, err :=
|
||||
doublestar.Match(strings.ReplaceAll(pattern, `\`, "/"), strings.ReplaceAll(path, `\`, "/"))
|
||||
if err != nil {
|
||||
fmt.Printf("Error matching pattern: %v\n", err)
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -9,20 +9,22 @@ import (
|
|||
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
|
||||
"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/reloader"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const version = "1.0.4"
|
||||
|
||||
func main() {
|
||||
done := RegisterSignals()
|
||||
needsSignals := true
|
||||
|
||||
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 {
|
||||
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
|
||||
|
|
@ -56,6 +58,15 @@ func main() {
|
|||
slog.Debug("Running task:", slog.String("task", taskName))
|
||||
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" {
|
||||
fmt.Printf("Running in watch mode\n")
|
||||
os.Setenv("ENV", "development")
|
||||
|
|
@ -67,21 +78,9 @@ func main() {
|
|||
fmt.Printf("Generating CSS...\n")
|
||||
css.GenerateCss(process.ExitOnError)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run.EntGenerate()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
// generate ast needs to be run after css generation
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
run.EntGenerate()
|
||||
|
||||
fmt.Printf("Starting server...\n")
|
||||
process.KillAll()
|
||||
|
|
@ -90,7 +89,22 @@ func main() {
|
|||
}()
|
||||
startWatcher(reloader.OnFileChange)
|
||||
} 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)
|
||||
fmt.Print("Enter entity name:")
|
||||
text, _ := reader.ReadString('\n')
|
||||
|
|
@ -104,10 +118,10 @@ func main() {
|
|||
} else if taskName == "css" {
|
||||
_ = css.GenerateCss(process.ExitOnError)
|
||||
} else if taskName == "ast" {
|
||||
css.GenerateCss(process.ExitOnError)
|
||||
_ = astgen.GenAst(process.ExitOnError)
|
||||
} else if taskName == "run" {
|
||||
_ = astgen.GenAst(process.ExitOnError)
|
||||
_ = css.GenerateCss(process.ExitOnError)
|
||||
run.MakeBuildable()
|
||||
_ = run.Server(process.ExitOnError)
|
||||
} else if taskName == "template" {
|
||||
name := ""
|
||||
|
|
|
|||
|
|
@ -2,15 +2,20 @@ package astgen
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"golang.org/x/mod/modfile"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Page struct {
|
||||
|
|
@ -24,6 +29,7 @@ type Partial struct {
|
|||
FuncName string
|
||||
Package string
|
||||
Import string
|
||||
Path string
|
||||
}
|
||||
|
||||
const GeneratedDirName = "__htmgo"
|
||||
|
|
@ -34,6 +40,36 @@ const ModuleName = "github.com/maddalax/htmgo/framework/h"
|
|||
var PackageName = fmt.Sprintf("package %s", 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 {
|
||||
// Use filepath.Clean to normalize the paths
|
||||
dir1 = filepath.Clean(dir1)
|
||||
|
|
@ -59,9 +95,9 @@ func sliceCommonPrefix(dir1, dir2 string) string {
|
|||
|
||||
// Return the longer one
|
||||
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) {
|
||||
|
|
@ -103,7 +139,8 @@ func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial
|
|||
if selectorExpr.Sel.Name == "Partial" {
|
||||
p := Partial{
|
||||
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,
|
||||
}
|
||||
if predicate(p) {
|
||||
|
|
@ -169,8 +206,8 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
|
|||
if selectorExpr.Sel.Name == "Page" {
|
||||
pages = append(pages, Page{
|
||||
Package: node.Name.Name,
|
||||
Import: strings.ReplaceAll(filepath.Dir(path), `\`, `/`),
|
||||
Path: path,
|
||||
Import: normalizePath(filepath.Dir(path)),
|
||||
Path: normalizePath(path),
|
||||
FuncName: funcDecl.Name.Name,
|
||||
})
|
||||
break
|
||||
|
|
@ -254,12 +291,18 @@ func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
|
|||
}
|
||||
|
||||
func writePartialsFile() {
|
||||
config := dirutil.GetConfig()
|
||||
|
||||
cwd := process.GetWorkingDir()
|
||||
partialPath := filepath.Join(cwd, "partials")
|
||||
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
|
||||
return partial.FuncName != "GetPartialFromContext"
|
||||
})
|
||||
|
||||
partials = h.Filter(partials, func(partial Partial) bool {
|
||||
return !dirutil.IsGlobExclude(partial.Path, config.AutomaticPartialRoutingIgnore)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
|
|
@ -317,6 +360,7 @@ func formatRoute(path string) string {
|
|||
}
|
||||
|
||||
func writePagesFile() {
|
||||
config := dirutil.GetConfig()
|
||||
|
||||
builder := NewCodeBuilder(nil)
|
||||
builder.AppendLine(GeneratedFileLine)
|
||||
|
|
@ -326,6 +370,10 @@ func writePagesFile() {
|
|||
|
||||
pages, _ := findPublicFuncsReturningHPage("pages")
|
||||
|
||||
pages = h.Filter(pages, func(page Page) bool {
|
||||
return !dirutil.IsGlobExclude(page.Path, config.AutomaticPageRoutingIgnore)
|
||||
})
|
||||
|
||||
if len(pages) > 0 {
|
||||
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 {
|
||||
wd := process.GetWorkingDir()
|
||||
modPath := filepath.Join(wd, "go.mod")
|
||||
|
|
@ -392,6 +500,7 @@ func GenAst(flags ...process.RunFlag) error {
|
|||
}
|
||||
writePartialsFile()
|
||||
writePagesFile()
|
||||
writeAssetsFile()
|
||||
|
||||
WriteFile("__htmgo/setup-generated.go", func(content *ast.File) string {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,16 +7,3 @@ import (
|
|||
func PanicF(format string, args ...interface{}) {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
50
cli/htmgo/tasks/formatter/formatter.go
Normal 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
|
||||
}
|
||||
|
|
@ -12,7 +12,10 @@ func KillProcess(process CmdWithFlags) error {
|
|||
if process.Cmd == nil || process.Cmd.Process == 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)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ func OnShutdown() {
|
|||
}
|
||||
}
|
||||
// give it a second
|
||||
time.Sleep(time.Second * 2)
|
||||
time.Sleep(time.Second * 1)
|
||||
// force kill
|
||||
KillAll()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@ import (
|
|||
"os"
|
||||
)
|
||||
|
||||
func Build() {
|
||||
func MakeBuildable() {
|
||||
copyassets.CopyAssets()
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
css.GenerateCss(process.ExitOnError)
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
}
|
||||
|
||||
func Build() {
|
||||
MakeBuildable()
|
||||
|
||||
process.RunOrExit(process.NewRawCommand("", "mkdir -p ./dist"))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
package run
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func Setup() {
|
||||
process.RunOrExit(process.NewRawCommand("", "go mod download"))
|
||||
process.RunOrExit(process.NewRawCommand("", "go mod tidy"))
|
||||
|
||||
copyassets.CopyAssets()
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
css.GenerateCss(process.ExitOnError)
|
||||
|
||||
MakeBuildable()
|
||||
EntGenerate()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -11,18 +11,25 @@ import (
|
|||
func MessageRow(message *Message) *h.Element {
|
||||
return h.Div(
|
||||
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.Div(
|
||||
h.Class("flex flex-col gap-1"),
|
||||
h.Div(
|
||||
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.Article(
|
||||
h.Class("break-words whitespace-normal"), // Ensure message text wraps correctly
|
||||
h.P(h.Text(message.Message)),
|
||||
h.Class("break-words whitespace-normal"),
|
||||
// Ensure message text wraps correctly
|
||||
h.P(
|
||||
h.Text(message.Message),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,12 +28,28 @@ func Button(props ButtonProps) h.Ren {
|
|||
text := h.Text(props.Text)
|
||||
|
||||
button := h.Button(
|
||||
h.If(props.Id != "", h.Id(props.Id)),
|
||||
h.If(props.Children != nil, h.Children(props.Children...)),
|
||||
h.If(
|
||||
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.If(props.Get != "", h.Get(props.Get)),
|
||||
h.If(props.Target != "", h.HxTarget(props.Target)),
|
||||
h.IfElse(props.Type != "", h.Type(props.Type), h.Type("button")),
|
||||
h.If(
|
||||
props.Get != "",
|
||||
h.Get(props.Get),
|
||||
),
|
||||
h.If(
|
||||
props.Target != "",
|
||||
h.HxTarget(props.Target),
|
||||
),
|
||||
h.IfElse(
|
||||
props.Type != "",
|
||||
h.Type(props.Type),
|
||||
h.Type("button"),
|
||||
),
|
||||
text,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ 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")),
|
||||
h.If(
|
||||
error != "",
|
||||
h.Class("p-4 bg-rose-400 text-white rounded"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ type InputProps struct {
|
|||
}
|
||||
|
||||
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"),
|
||||
))
|
||||
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"
|
||||
|
|
@ -32,18 +35,41 @@ func Input(props InputProps) *h.Element {
|
|||
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)),
|
||||
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.If(
|
||||
props.Id != "",
|
||||
h.Id(props.Id),
|
||||
),
|
||||
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,
|
||||
h.Div(
|
||||
h.Id(props.Id+"-error"),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ go 1.23.0
|
|||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.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/puzpuzpuz/xsync/v3 v3.4.0
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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/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 v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns=
|
||||
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 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.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -17,13 +17,10 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
|
|||
RootPage(
|
||||
h.Div(
|
||||
h.TriggerChildren(),
|
||||
|
||||
h.Attribute("sse-connect", fmt.Sprintf("/sse/chat/%s", roomId)),
|
||||
|
||||
h.HxOnSseOpen(
|
||||
js.ConsoleLog("Connected to chat room"),
|
||||
),
|
||||
|
||||
h.HxOnSseError(
|
||||
js.EvalJs(fmt.Sprintf(`
|
||||
const reason = e.detail.event.data
|
||||
|
|
@ -38,35 +35,27 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
|
|||
}
|
||||
`, roomId, roomId)),
|
||||
),
|
||||
|
||||
// Adjusted flex properties for responsive layout
|
||||
h.Class("flex flex-row h-screen bg-neutral-100 overflow-x-hidden"),
|
||||
|
||||
// Collapse Button for mobile
|
||||
CollapseButton(),
|
||||
|
||||
// Sidebar for connected users
|
||||
UserSidebar(),
|
||||
|
||||
h.Div(
|
||||
// Adjusted to fill height and width
|
||||
h.Class("flex flex-col h-full w-full bg-white p-4 overflow-hidden"),
|
||||
|
||||
// Room name at the top, fixed
|
||||
CachedRoomHeader(ctx),
|
||||
|
||||
h.HxAfterSseMessage(
|
||||
js.EvalJsOnSibling("#messages",
|
||||
`element.scrollTop = element.scrollHeight;`),
|
||||
),
|
||||
|
||||
// Chat Messages
|
||||
h.Div(
|
||||
h.Id("messages"),
|
||||
// Adjusted flex properties and removed max-width
|
||||
h.Class("flex flex-col gap-4 mb-4 overflow-auto flex-grow w-full pt-[50px]"),
|
||||
),
|
||||
|
||||
// Chat Input at the bottom
|
||||
Form(),
|
||||
),
|
||||
|
|
@ -91,7 +80,10 @@ func roomNameHeader(ctx *h.RequestContext) *h.Element {
|
|||
}
|
||||
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.H2F(room.Name, h.Class("text-lg font-bold")),
|
||||
h.H2F(
|
||||
room.Name,
|
||||
h.Class("text-lg font-bold"),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("absolute right-5 top-3 cursor-pointer"),
|
||||
h.Text("Share"),
|
||||
|
|
@ -108,7 +100,10 @@ func UserSidebar() *h.Element {
|
|||
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.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), ""),
|
||||
),
|
||||
h.A(
|
||||
|
|
@ -121,9 +116,11 @@ func UserSidebar() *h.Element {
|
|||
|
||||
func CollapseButton() *h.Element {
|
||||
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.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(
|
||||
js.EvalJs(`
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
|
|
@ -131,13 +128,16 @@ func CollapseButton() *h.Element {
|
|||
sidebar.classList.toggle('flex');
|
||||
`),
|
||||
),
|
||||
h.UnsafeRaw("☰"), // The icon for collapsing the sidebar
|
||||
h.UnsafeRaw("☰"),
|
||||
|
||||
// The icon for collapsing the sidebar
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func MessageInput() *h.Element {
|
||||
return h.Input("text",
|
||||
return h.Input(
|
||||
"text",
|
||||
h.Id("message-input"),
|
||||
h.Required(),
|
||||
h.Class("p-4 rounded-md border border-slate-200 w-full focus:outline-none focus:ring focus:ring-slate-200"),
|
||||
|
|
|
|||
|
|
@ -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.Div(
|
||||
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.Attribute("hx-swap", "none"),
|
||||
h.PostPartial(partials.CreateOrJoinRoom),
|
||||
h.Class("flex flex-col gap-6"),
|
||||
|
||||
// Username input at the top
|
||||
components.Input(components.InputProps{
|
||||
Id: "username",
|
||||
|
|
@ -30,11 +32,9 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
|
|||
h.MaxLength(15),
|
||||
},
|
||||
}),
|
||||
|
||||
// Single box for Create or Join a Chat Room
|
||||
h.Div(
|
||||
h.Class("p-4 border border-gray-300 rounded-md flex flex-col gap-6"),
|
||||
|
||||
// Create New Chat Room input
|
||||
components.Input(components.InputProps{
|
||||
Name: "new-chat-room",
|
||||
|
|
@ -45,15 +45,20 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
|
|||
h.MaxLength(20),
|
||||
},
|
||||
}),
|
||||
|
||||
// OR divider
|
||||
h.Div(
|
||||
h.Class("flex items-center justify-center gap-4"),
|
||||
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")),
|
||||
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
|
||||
components.Input(components.InputProps{
|
||||
Id: "join-chat-room",
|
||||
|
|
@ -67,10 +72,8 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
|
|||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// Error message
|
||||
components.FormError(""),
|
||||
|
||||
// Submit button at the bottom
|
||||
components.PrimaryButton(components.ButtonProps{
|
||||
Type: "submit",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@ module hackernews
|
|||
|
||||
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 (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.1.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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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/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 v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns=
|
||||
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"github.com/maddalax/htmgo/framework/h"
|
||||
"hackernews/internal/batch"
|
||||
"hackernews/internal/httpjson"
|
||||
"hackernews/internal/sanitize"
|
||||
"hackernews/internal/timeformat"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
|
@ -132,6 +133,8 @@ func GetComment(id int) (*Comment, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Text = sanitize.Sanitize(c.Text)
|
||||
c.By = sanitize.Sanitize(c.By)
|
||||
c.Time = timeformat.ParseUnix(c.TimeRaw)
|
||||
return c, nil
|
||||
}
|
||||
|
|
@ -141,6 +144,9 @@ func GetStory(id int) (*Story, error) {
|
|||
if err != nil {
|
||||
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)
|
||||
return s, nil
|
||||
}
|
||||
|
|
|
|||
9
examples/hackernews/internal/sanitize/sanitize.go
Normal 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)
|
||||
}
|
||||
|
|
@ -5,14 +5,17 @@ import (
|
|||
)
|
||||
|
||||
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.Attribute("target", "_blank"),
|
||||
h.Text("Built with htmgo.dev"),
|
||||
)
|
||||
|
||||
return h.Html(
|
||||
h.HxExtensions(h.BaseExtensions()),
|
||||
h.HxExtensions(
|
||||
h.BaseExtensions(),
|
||||
),
|
||||
h.Head(
|
||||
h.Meta("viewport", "width=device-width, initial-scale=1"),
|
||||
h.Link("/public/favicon.ico", "icon"),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"github.com/maddalax/htmgo/framework/h"
|
||||
"hackernews/internal/batch"
|
||||
"hackernews/internal/news"
|
||||
"hackernews/internal/sanitize"
|
||||
"hackernews/internal/timeformat"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -13,7 +14,12 @@ import (
|
|||
func StoryComments(ctx *h.RequestContext) *h.Partial {
|
||||
return h.NewPartial(
|
||||
h.Fragment(
|
||||
h.OobSwap(ctx, h.Div(h.Id("comments-loader"))),
|
||||
h.OobSwap(
|
||||
ctx,
|
||||
h.Div(
|
||||
h.Id("comments-loader"),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("flex flex-col gap-3 prose max-w-none"),
|
||||
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-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.If(nesting > 0, h.Class("pl-4")),
|
||||
h.If(
|
||||
nesting > 0,
|
||||
h.Class("pl-4"),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("flex gap-1 items-center"),
|
||||
h.Div(
|
||||
h.Class("font-bold text-rose-500"),
|
||||
h.UnsafeRaw(item.By),
|
||||
h.UnsafeRaw(sanitize.Sanitize(item.By)),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
|
|
@ -74,15 +86,18 @@ func Comment(item news.Comment, nesting int) *h.Element {
|
|||
),
|
||||
h.Div(
|
||||
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),
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"hackernews/components"
|
||||
"hackernews/internal/news"
|
||||
"hackernews/internal/parse"
|
||||
"hackernews/internal/sanitize"
|
||||
"hackernews/internal/timeformat"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -57,13 +58,18 @@ func StorySidebar(ctx *h.RequestContext) *h.Partial {
|
|||
|
||||
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)
|
||||
|
||||
body := h.Aside(
|
||||
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.Div(
|
||||
h.Class("flex flex-col gap-1"),
|
||||
|
|
@ -99,7 +105,9 @@ func SidebarTitle(defaultCategory string) *h.Element {
|
|||
h.Text("Hacker News"),
|
||||
),
|
||||
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.List(news.Categories, func(item news.Category, index int) *h.Element {
|
||||
return CategoryBadge(defaultCategory, item)
|
||||
|
|
@ -114,7 +122,13 @@ func CategoryBadge(defaultCategory string, category news.Category) *h.Element {
|
|||
category.Name,
|
||||
selected,
|
||||
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.Div(
|
||||
h.Class("font-bold"),
|
||||
h.UnsafeRaw(item.Title),
|
||||
h.UnsafeRaw(sanitize.Sanitize(item.Title)),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"hackernews/internal/news"
|
||||
"hackernews/internal/sanitize"
|
||||
"hackernews/internal/timeformat"
|
||||
"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.H5(
|
||||
h.Class("flex gap-2 items-left font-bold"),
|
||||
h.UnsafeRaw(story.Title),
|
||||
h.UnsafeRaw(sanitize.Sanitize(story.Title)),
|
||||
),
|
||||
h.A(
|
||||
h.Href(story.Url),
|
||||
|
|
@ -66,7 +67,7 @@ func StoryBody(story *news.Story) *h.Element {
|
|||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
h.UnsafeRaw(story.Text),
|
||||
h.UnsafeRaw(sanitize.Sanitize(story.Text)),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600 mt-2"),
|
||||
|
|
|
|||
11
examples/simple-auth/.dockerignore
Normal 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
|
|
@ -0,0 +1,6 @@
|
|||
/assets/dist
|
||||
tmp
|
||||
node_modules
|
||||
.idea
|
||||
__htmgo
|
||||
dist
|
||||
36
examples/simple-auth/Dockerfile
Normal 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"]
|
||||
20
examples/simple-auth/Taskfile.yml
Normal 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
|
||||
13
examples/simple-auth/assets.go
Normal 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()
|
||||
}
|
||||
3
examples/simple-auth/assets/css/input.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
BIN
examples/simple-auth/assets/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
examples/simple-auth/assets/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
16
examples/simple-auth/assets_prod.go
Normal 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
|
||||
}
|
||||
14
examples/simple-auth/go.mod
Normal 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
|
||||
)
|
||||
20
examples/simple-auth/go.sum
Normal 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=
|
||||
10
examples/simple-auth/htmgo.yml
Normal 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"]
|
||||
31
examples/simple-auth/internal/db/db.go
Normal 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,
|
||||
}
|
||||
}
|
||||
26
examples/simple-auth/internal/db/models.go
Normal 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
|
||||
}
|
||||
25
examples/simple-auth/internal/db/provider.go
Normal 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)
|
||||
}
|
||||
31
examples/simple-auth/internal/db/queries.sql
Normal 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 = ?;
|
||||
123
examples/simple-auth/internal/db/queries.sql.go
Normal 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
|
||||
}
|
||||
28
examples/simple-auth/internal/db/schema.sql
Normal 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);
|
||||
17
examples/simple-auth/internal/embedded/os.go
Normal 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{}
|
||||
}
|
||||
115
examples/simple-auth/internal/user/handler.go
Normal 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
|
||||
}
|
||||
17
examples/simple-auth/internal/user/http.go
Normal 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
|
||||
}
|
||||
18
examples/simple-auth/internal/user/password.go
Normal 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
|
||||
}
|
||||
83
examples/simple-auth/internal/user/session.go
Normal 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
|
||||
}
|
||||
35
examples/simple-auth/main.go
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
72
examples/simple-auth/pages/index.go
Normal 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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
49
examples/simple-auth/pages/login.go
Normal 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"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
23
examples/simple-auth/pages/logout.go
Normal 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
|
||||
}
|
||||
49
examples/simple-auth/pages/register.go
Normal 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"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
34
examples/simple-auth/pages/root.go
Normal 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...),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
36
examples/simple-auth/partials/profile.go
Normal 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("/")
|
||||
}
|
||||
62
examples/simple-auth/partials/user.go
Normal 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("/")
|
||||
}
|
||||
9
examples/simple-auth/sqlc.yaml
Normal 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"
|
||||
5
examples/simple-auth/tailwind.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["**/*.go"],
|
||||
plugins: [],
|
||||
};
|
||||
41
examples/simple-auth/ui/button.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
20
examples/simple-auth/ui/error.go
Normal 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),
|
||||
)
|
||||
}
|
||||
81
examples/simple-auth/ui/input.go
Normal 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
|
||||
}
|
||||
36
examples/simple-auth/ui/login.go
Normal 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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ go 1.23.0
|
|||
require (
|
||||
entgo.io/ent v0.14.1
|
||||
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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
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 v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
|
||||
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.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import (
|
|||
|
||||
func RootPage(children ...h.Ren) h.Ren {
|
||||
return h.Html(
|
||||
h.HxExtension(h.BaseExtensions()),
|
||||
h.HxExtension(
|
||||
h.BaseExtensions(),
|
||||
),
|
||||
h.Head(
|
||||
h.Meta("viewport", "width=device-width, initial-scale=1"),
|
||||
h.Meta("title", "htmgo todo mvc"),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import (
|
|||
func TaskListPage(ctx *h.RequestContext) *h.Page {
|
||||
|
||||
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(
|
||||
|
|
@ -21,7 +24,9 @@ func TaskListPage(ctx *h.RequestContext) *h.Page {
|
|||
title,
|
||||
task.Card(ctx),
|
||||
h.Children(
|
||||
h.Div(h.Text("Double-click to edit a todo")),
|
||||
h.Div(
|
||||
h.Text("Double-click to edit a todo"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ func Input(list []*ent.Task) *h.Element {
|
|||
h.Name("name"),
|
||||
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.Post(h.GetPartialPath(Create)),
|
||||
h.Post(
|
||||
h.GetPartialPath(Create),
|
||||
),
|
||||
h.HxTrigger(hx.OnEvent(hx.TriggerKeyUpEnter)),
|
||||
),
|
||||
CompleteAllIcon(list),
|
||||
|
|
@ -66,23 +68,34 @@ func Input(list []*ent.Task) *h.Element {
|
|||
}
|
||||
|
||||
func CompleteAllIcon(list []*ent.Task) *h.Element {
|
||||
notCompletedCount := len(h.Filter(list, func(item *ent.Task) bool {
|
||||
return item.CompletedAt == nil
|
||||
}))
|
||||
notCompletedCount := len(
|
||||
h.Filter(list, func(item *ent.Task) bool {
|
||||
return item.CompletedAt == nil
|
||||
}),
|
||||
)
|
||||
|
||||
return h.Div(
|
||||
h.ClassX("absolute top-1 left-5 p-2 rotate-90 text-3xl cursor-pointer", map[string]bool{
|
||||
"text-slate-400": notCompletedCount > 0,
|
||||
}), h.UnsafeRaw("›"),
|
||||
h.PostPartialWithQs(CompleteAll, h.NewQs("complete", h.Ternary(notCompletedCount > 0, "true", "false"))),
|
||||
}),
|
||||
h.UnsafeRaw("›"),
|
||||
h.PostPartialWithQs(
|
||||
CompleteAll,
|
||||
h.NewQs(
|
||||
"complete",
|
||||
h.Ternary(notCompletedCount > 0, "true", "false"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Footer(list []*ent.Task, activeTab Tab) *h.Element {
|
||||
|
||||
notCompletedCount := len(h.Filter(list, func(item *ent.Task) bool {
|
||||
return item.CompletedAt == nil
|
||||
}))
|
||||
notCompletedCount := len(
|
||||
h.Filter(list, func(item *ent.Task) bool {
|
||||
return item.CompletedAt == nil
|
||||
}),
|
||||
)
|
||||
|
||||
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.List(tabs, func(tab Tab, index int) *h.Element {
|
||||
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{
|
||||
"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,
|
||||
}),
|
||||
CompleteIcon(task),
|
||||
h.IfElse(editing,
|
||||
h.IfElse(
|
||||
editing,
|
||||
h.Div(
|
||||
h.Class("flex-1 h-full"),
|
||||
h.Form(
|
||||
h.Class("h-full"),
|
||||
h.Input("text",
|
||||
h.Input(
|
||||
"text",
|
||||
h.Name("task"),
|
||||
h.Value(task.ID.String()),
|
||||
h.Class("hidden"),
|
||||
|
|
@ -168,30 +188,43 @@ func Task(task *ent.Task, editing bool) *h.Element {
|
|||
),
|
||||
),
|
||||
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{
|
||||
"line-through text-slate-400": task.CompletedAt != nil,
|
||||
}),
|
||||
h.Text(task.Name),
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func CompleteIcon(task *ent.Task) *h.Element {
|
||||
return h.Div(
|
||||
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.Div(
|
||||
h.ClassX("w-10 h-10 border rounded-full flex items-center justify-center", map[string]bool{
|
||||
"border-green-500": 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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
`)),
|
||||
`),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -199,46 +232,75 @@ func CompleteIcon(task *ent.Task) *h.Element {
|
|||
func UpdateName(ctx *h.RequestContext) *h.Partial {
|
||||
id, err := uuid.Parse(ctx.FormValue("task"))
|
||||
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")
|
||||
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 {
|
||||
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)
|
||||
task, err := service.Get(id)
|
||||
|
||||
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)
|
||||
|
||||
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(
|
||||
h.OobSwap(ctx, Task(task, false)))
|
||||
h.OobSwap(ctx, Task(task, false)),
|
||||
)
|
||||
}
|
||||
|
||||
func EditNameForm(ctx *h.RequestContext) *h.Partial {
|
||||
id, err := uuid.Parse(ctx.QueryParam("id"))
|
||||
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)
|
||||
task, err := service.Get(id)
|
||||
|
||||
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(
|
||||
|
|
@ -249,21 +311,36 @@ func EditNameForm(ctx *h.RequestContext) *h.Partial {
|
|||
func ToggleCompleted(ctx *h.RequestContext) *h.Partial {
|
||||
id, err := uuid.Parse(ctx.QueryParam("id"))
|
||||
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)
|
||||
task, err := service.Get(id)
|
||||
|
||||
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.
|
||||
Ternary(task.CompletedAt == nil, true, false))
|
||||
task, err = service.SetCompleted(
|
||||
task.ID,
|
||||
h.
|
||||
Ternary(task.CompletedAt == nil, true, false),
|
||||
)
|
||||
|
||||
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()
|
||||
|
|
@ -282,7 +359,9 @@ func CompleteAll(ctx *h.RequestContext) *h.Partial {
|
|||
|
||||
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 {
|
||||
|
|
@ -291,7 +370,9 @@ func ClearCompleted(ctx *h.RequestContext) *h.Partial {
|
|||
|
||||
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 {
|
||||
|
|
@ -300,7 +381,9 @@ func Create(ctx *h.RequestContext) *h.Partial {
|
|||
if len(name) > 150 {
|
||||
return h.NewPartial(
|
||||
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 {
|
||||
return h.NewPartial(
|
||||
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 {
|
||||
return h.NewPartial(h.Div(h.Text("failed to create")))
|
||||
return h.NewPartial(
|
||||
h.Div(
|
||||
h.Text("failed to create"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
list, err = service.List()
|
||||
|
|
@ -338,8 +427,12 @@ func ChangeTab(ctx *h.RequestContext) *h.Partial {
|
|||
|
||||
tab := ctx.QueryParam("tab")
|
||||
|
||||
return h.SwapManyPartialWithHeaders(ctx,
|
||||
h.PushQsHeader(ctx, h.NewQs("tab", tab)),
|
||||
return h.SwapManyPartialWithHeaders(
|
||||
ctx,
|
||||
h.PushQsHeader(
|
||||
ctx,
|
||||
h.NewQs("tab", tab),
|
||||
),
|
||||
List(list, tab),
|
||||
Footer(list, tab),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ module github.com/maddalax/htmgo/framework-ui
|
|||
|
||||
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 (
|
||||
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||
|
|
|
|||
|
|
@ -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/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 v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns=
|
||||
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 h1:xr5dOwDzFZgZlgL3MmggSS9p+VeC0JawNS6tWBI3XUM=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ htmx.defineExtension("mutation-error", {
|
|||
}
|
||||
const status = evt.detail.xhr.status;
|
||||
if (status >= 400) {
|
||||
htmx.findAll("[hx-on\\:\\:mutation-error]").forEach((element) => {
|
||||
htmx.trigger(element, "htmx:mutation-error", { status });
|
||||
document.querySelectorAll("*").forEach((element) => {
|
||||
if (element.hasAttribute("hx-on::on-mutation-error")) {
|
||||
htmx.trigger(element, "htmx:on-mutation-error", { status });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import (
|
|||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProjectConfig struct {
|
||||
Tailwind bool `yaml:"tailwind"`
|
||||
WatchIgnore []string `yaml:"watch_ignore"`
|
||||
WatchFiles []string `yaml:"watch_files"`
|
||||
Tailwind bool `yaml:"tailwind"`
|
||||
WatchIgnore []string `yaml:"watch_ignore"`
|
||||
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 {
|
||||
|
|
@ -22,10 +26,11 @@ func DefaultProjectConfig() *ProjectConfig {
|
|||
WatchFiles: []string{
|
||||
"**/*.go", "**/*.html", "**/*.css", "**/*.js", "**/*.json", "**/*.yaml", "**/*.yml", "**/*.md",
|
||||
},
|
||||
PublicAssetPath: "/public",
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *ProjectConfig) EnhanceWithDefaults() *ProjectConfig {
|
||||
func (cfg *ProjectConfig) Enhance() *ProjectConfig {
|
||||
defaultCfg := DefaultProjectConfig()
|
||||
if len(cfg.WatchFiles) == 0 {
|
||||
cfg.WatchFiles = defaultCfg.WatchFiles
|
||||
|
|
@ -33,9 +38,43 @@ func (cfg *ProjectConfig) EnhanceWithDefaults() *ProjectConfig {
|
|||
if len(cfg.WatchIgnore) == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
func Get() *ProjectConfig {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return DefaultProjectConfig()
|
||||
}
|
||||
config := FromConfigFile(cwd)
|
||||
return config
|
||||
}
|
||||
|
||||
func FromConfigFile(workingDir string) *ProjectConfig {
|
||||
defaultCfg := DefaultProjectConfig()
|
||||
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()))
|
||||
os.Exit(1)
|
||||
}
|
||||
return cfg.EnhanceWithDefaults()
|
||||
return cfg.Enhance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,53 @@ func TestShouldNotSetTailwindTrue(t *testing.T) {
|
|||
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 {
|
||||
temp := os.TempDir()
|
||||
os.Mkdir(temp, 0755)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
package datastructures
|
||||
|
||||
// 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
|
||||
}
|
||||
package orderedmap
|
||||
|
||||
type Entry[K comparable, V any] struct {
|
||||
Key K
|
||||
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.
|
||||
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))
|
||||
for i, key := range om.keys {
|
||||
entries[i] = Entry[K, V]{
|
||||
|
|
@ -23,16 +29,16 @@ func (om *OrderedMap[K, V]) Entries() []Entry[K, V] {
|
|||
return entries
|
||||
}
|
||||
|
||||
// NewOrderedMap creates a new OrderedMap.
|
||||
func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] {
|
||||
return &OrderedMap[K, V]{
|
||||
// New creates a new Map.
|
||||
func New[K comparable, V any]() *Map[K, V] {
|
||||
return &Map[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) {
|
||||
// Set adds or updates a key-value pair in the Map.
|
||||
func (om *Map[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
|
||||
|
|
@ -41,18 +47,18 @@ func (om *OrderedMap[K, V]) Set(key K, value V) {
|
|||
}
|
||||
|
||||
// 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]
|
||||
return value, exists
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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))
|
||||
for i, key := range om.keys {
|
||||
values[i] = om.values[key]
|
||||
|
|
@ -61,8 +67,8 @@ func (om *OrderedMap[K, V]) Values() []V {
|
|||
return values
|
||||
}
|
||||
|
||||
// Delete removes a key-value pair from the OrderedMap.
|
||||
func (om *OrderedMap[K, V]) Delete(key K) {
|
||||
// Delete removes a key-value pair from the Map.
|
||||
func (om *Map[K, V]) Delete(key K) {
|
||||
if _, exists := om.values[key]; exists {
|
||||
// Remove the key from the map
|
||||
delete(om.values, key)
|
||||
63
framework/datastructure/orderedmap/orderedmap_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -3,9 +3,6 @@ package h
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/maddalax/htmgo/framework/hx"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -13,6 +10,10 @@ import (
|
|||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/maddalax/htmgo/framework/hx"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
)
|
||||
|
||||
type RequestContext struct {
|
||||
|
|
@ -33,6 +34,37 @@ func GetRequestContext(r *http.Request) *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 {
|
||||
return c.Request.FormValue(key)
|
||||
}
|
||||
|
|
@ -91,6 +123,10 @@ func (c *RequestContext) Get(key string) interface{} {
|
|||
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 {
|
||||
return c.locator
|
||||
}
|
||||
|
|
@ -106,6 +142,7 @@ type App struct {
|
|||
Router *chi.Mux
|
||||
}
|
||||
|
||||
// Start starts the htmgo server
|
||||
func Start(opts AppOpts) {
|
||||
router := chi.NewRouter()
|
||||
instance := App{
|
||||
|
|
@ -182,10 +219,9 @@ func (app *App) start() {
|
|||
}
|
||||
|
||||
port := ":3000"
|
||||
slog.Info(fmt.Sprintf("Server started on port %s", port))
|
||||
err := http.ListenAndServe(port, app.Router)
|
||||
slog.Info(fmt.Sprintf("Server started at localhost%s", port))
|
||||
|
||||
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
|
||||
// and try again
|
||||
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.Run()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeHtml(w http.ResponseWriter, element Ren) error {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, err := fmt.Fprint(w, Render(element))
|
||||
if element == nil {
|
||||
return nil
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, err := fmt.Fprint(w, Render(element, WithDocType()))
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func PartialViewWithHeaders(w http.ResponseWriter, headers *Headers, partial *Partial) error {
|
||||
if partial == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if partial.Headers != nil {
|
||||
for s, a := range *partial.Headers {
|
||||
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 {
|
||||
if partial == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if partial.Headers != nil {
|
||||
for s, a := range *partial.Headers {
|
||||
w.Header().Set(s, a)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
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 {
|
||||
var result []T
|
||||
seen := make(map[string]bool)
|
||||
|
|
@ -13,6 +18,45 @@ func Unique[T any](slice []T, key func(item T) string) []T {
|
|||
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 {
|
||||
var result []T
|
||||
for _, v := range slice {
|
||||
|
|
@ -23,6 +67,7 @@ func Filter[T any](slice []T, predicate func(item T) bool) []T {
|
|||
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 {
|
||||
var result []U
|
||||
for _, v := range slice {
|
||||
|
|
|
|||
102
framework/h/array_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package h
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/datastructure/orderedmap"
|
||||
"github.com/maddalax/htmgo/framework/hx"
|
||||
"github.com/maddalax/htmgo/framework/internal/datastructure"
|
||||
"github.com/maddalax/htmgo/framework/internal/util"
|
||||
|
|
@ -11,7 +12,7 @@ import (
|
|||
type AttributeMap = map[string]any
|
||||
|
||||
type AttributeMapOrdered struct {
|
||||
data *datastructure.OrderedMap[string, string]
|
||||
data *orderedmap.Map[string, string]
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func NewAttributeMap(pairs ...string) *AttributeMapOrdered {
|
||||
m := datastructure.NewOrderedMap[string, string]()
|
||||
m := orderedmap.New[string, string]()
|
||||
if len(pairs)%2 == 0 {
|
||||
for i := 0; i < len(pairs); i++ {
|
||||
m.Set(pairs[i], pairs[i+1])
|
||||
|
|
@ -90,9 +91,7 @@ func Checked() Ren {
|
|||
}
|
||||
|
||||
func Id(value string) Ren {
|
||||
if strings.HasPrefix(value, "#") {
|
||||
value = value[1:]
|
||||
}
|
||||
value = strings.TrimPrefix(value, "#")
|
||||
return Attribute("id", value)
|
||||
}
|
||||
|
||||
|
|
@ -121,27 +120,34 @@ func HxIndicator(tag string) *AttributeR {
|
|||
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 {
|
||||
return HxExtension("trigger-children")
|
||||
}
|
||||
|
||||
// HxTriggerString Adds a hx-trigger to an element based on a string of triggers
|
||||
func HxTriggerString(triggers ...string) *AttributeR {
|
||||
trigger := hx.NewStringTrigger(strings.Join(triggers, ", "))
|
||||
return Attribute(hx.TriggerAttr, trigger.ToString())
|
||||
}
|
||||
|
||||
// HxTrigger Adds a hx-trigger to an element
|
||||
func HxTrigger(opts ...hx.TriggerEvent) *AttributeR {
|
||||
return Attribute(hx.TriggerAttr, hx.NewTrigger(opts...).ToString())
|
||||
}
|
||||
|
||||
// HxTriggerClick Adds a hx-trigger="click" to an element
|
||||
func HxTriggerClick(opts ...hx.Modifier) *AttributeR {
|
||||
return HxTrigger(hx.OnClick(opts...))
|
||||
}
|
||||
|
||||
// HxExtension Adds a hx-ext to an element
|
||||
func HxExtension(value string) *AttributeR {
|
||||
return Attribute(hx.ExtAttr, value)
|
||||
}
|
||||
|
||||
// HxExtensions Adds multiple hx-ext to an element, separated by commas
|
||||
func HxExtensions(value ...string) Ren {
|
||||
return Attribute(hx.ExtAttr, strings.Join(value, ","))
|
||||
}
|
||||
|
|
@ -150,6 +156,8 @@ func JoinExtensions(attrs ...*AttributeR) Ren {
|
|||
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 {
|
||||
values := make([]string, 0, len(attrs))
|
||||
for _, a := range attrs {
|
||||
|
|
@ -190,10 +198,23 @@ func Hidden() Ren {
|
|||
return Attribute("style", "display:none")
|
||||
}
|
||||
|
||||
func Controls() Ren {
|
||||
return Attribute("controls", "")
|
||||
}
|
||||
|
||||
func Class(value ...string) *AttributeR {
|
||||
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 {
|
||||
builder := strings.Builder{}
|
||||
builder.WriteString(value)
|
||||
|
|
@ -207,6 +228,7 @@ func ClassX(value string, m ClassMap) Ren {
|
|||
return Class(builder.String())
|
||||
}
|
||||
|
||||
// MergeClasses merges multiple classes into a single class string
|
||||
func MergeClasses(classes ...string) string {
|
||||
if len(classes) == 1 {
|
||||
return classes[0]
|
||||
|
|
|
|||
160
framework/h/attribute_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -23,6 +23,10 @@ func NewPage(root Ren) *Page {
|
|||
}
|
||||
}
|
||||
|
||||
func EmptyPage() *Page {
|
||||
return NewPage(Fragment())
|
||||
}
|
||||
|
||||
func NewPageWithHttpMethod(httpMethod string, root *Element) *Page {
|
||||
return &Page{
|
||||
HttpMethod: httpMethod,
|
||||
|
|
@ -67,8 +71,12 @@ func SwapPartial(ctx *RequestContext, swap *Element) *Partial {
|
|||
SwapMany(ctx, swap))
|
||||
}
|
||||
|
||||
func IsEmptyPartial(partial *Partial) bool {
|
||||
return partial.Root.tag == "" && len(partial.Root.children) == 0
|
||||
}
|
||||
|
||||
func EmptyPartial() *Partial {
|
||||
return NewPartial(Fragment())
|
||||
return NewPartial(Empty())
|
||||
}
|
||||
|
||||
func SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial {
|
||||
|
|
|
|||
141
framework/h/base_test.go
Normal 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¶m2=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))
|
||||
}
|
||||
|
|
@ -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 {
|
||||
element := &Element{
|
||||
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 {
|
||||
element := &Element{
|
||||
tag: CachedNodeTag,
|
||||
|
|
@ -94,6 +99,8 @@ type ByKeyEntry struct {
|
|||
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 {
|
||||
element := &Element{
|
||||
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 {
|
||||
element := &Element{
|
||||
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 {
|
||||
element := &Element{
|
||||
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 {
|
||||
element := &Element{
|
||||
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 {
|
||||
element := &Element{
|
||||
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 {
|
||||
element := &Element{
|
||||
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 {
|
||||
element := &Element{
|
||||
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 {
|
||||
element := &Element{
|
||||
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() {
|
||||
c.html = ""
|
||||
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() {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
deletedCount := 0
|
||||
if c.isByKey == true {
|
||||
if c.isByKey {
|
||||
if c.byKeyCache != nil && c.byKeyExpiration != nil {
|
||||
for key := range c.byKeyCache {
|
||||
expir, ok := c.byKeyExpiration[key]
|
||||
|
|
@ -303,7 +330,7 @@ func (c *CachedNode) ClearExpired() {
|
|||
}
|
||||
|
||||
func (c *CachedNode) Render(ctx *RenderContext) {
|
||||
if c.isByKey == true {
|
||||
if c.isByKey {
|
||||
panic("CachedPerKey should not be rendered directly")
|
||||
} else {
|
||||
c.mutex.Lock()
|
||||
|
|
|
|||
|
|
@ -55,10 +55,12 @@ var re = regexp.MustCompile(`\s+`)
|
|||
func compareIgnoreSpaces(t *testing.T, actual, expected string) {
|
||||
expected = strings.ReplaceAll(expected, "\n", "")
|
||||
expected = strings.ReplaceAll(expected, "\t", "")
|
||||
expected = re.ReplaceAllString(expected, " ")
|
||||
actual = strings.ReplaceAll(actual, "\n", "")
|
||||
actual = strings.ReplaceAll(actual, "\t", "")
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -75,11 +77,11 @@ func TestJsEval(t *testing.T) {
|
|||
}
|
||||
|
||||
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'")), `
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
compareIgnoreSpaces(t, renderJs(t, SetClassOnSibling("button", "selected")), `
|
||||
if(!self.parentElement) { return; }let siblings = self.parentElement.querySelectorAll('button');
|
||||
siblings.forEach(function(element) {
|
||||
element.classList.add('selected')
|
||||
});
|
||||
compareIgnoreSpaces(t, renderJs(t, SetClassOnSibling("button", "selected")),
|
||||
// language=JavaScript
|
||||
`
|
||||
if(self.parentElement) {
|
||||
let siblings = self.parentElement.querySelectorAll('button');
|
||||
siblings.forEach(function(element) {
|
||||
element.classList.add('selected')
|
||||
});
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
func TestRemoveClassOnSibling(t *testing.T) {
|
||||
compareIgnoreSpaces(t, renderJs(t, RemoveClassOnSibling("button", "selected")), `
|
||||
if(!self.parentElement) { return; }let siblings = self.parentElement.querySelectorAll('button');
|
||||
siblings.forEach(function(element) {
|
||||
element.classList.remove('selected')
|
||||
});
|
||||
compareIgnoreSpaces(t, renderJs(t, RemoveClassOnSibling("button", "selected")),
|
||||
// language=JavaScript
|
||||
`
|
||||
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';")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package h
|
||||
|
||||
// If returns the node if the condition is true, otherwise returns an empty element
|
||||
func If(condition bool, node Ren) Ren {
|
||||
if condition {
|
||||
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 {
|
||||
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 {
|
||||
if condition {
|
||||
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 {
|
||||
if condition {
|
||||
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 {
|
||||
if condition {
|
||||
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 {
|
||||
if condition {
|
||||
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 {
|
||||
if ctx.isHxRequest {
|
||||
return node
|
||||
|
|
@ -51,6 +61,7 @@ func IfHtmxRequest(ctx *RequestContext, node Ren) Ren {
|
|||
return Empty()
|
||||
}
|
||||
|
||||
// ClassIf returns the class attribute if the condition is true, otherwise returns an empty element
|
||||
func ClassIf(condition bool, value string) Ren {
|
||||
if condition {
|
||||
return Class(value)
|
||||
|
|
@ -58,6 +69,7 @@ func ClassIf(condition bool, value string) Ren {
|
|||
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 {
|
||||
if condition {
|
||||
return Attribute(name, value)
|
||||
|
|
|
|||
92
framework/h/conditionals_test.go
Normal 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
|
||||
}
|
||||
22
framework/h/extensions_test.go
Normal 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)
|
||||
}
|
||||