diff --git a/.github/workflows/release-auth-example.yml b/.github/workflows/release-auth-example.yml new file mode 100644 index 0000000..ae84089 --- /dev/null +++ b/.github/workflows/release-auth-example.yml @@ -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 diff --git a/.github/workflows/release-chat-example.yml b/.github/workflows/release-chat-example.yml index 6f87767..6efb434 100644 --- a/.github/workflows/release-chat-example.yml +++ b/.github/workflows/release-chat-example.yml @@ -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: diff --git a/.github/workflows/run-framework-tests.yml b/.github/workflows/run-framework-tests.yml index 1f82cdd..87cce04 100644 --- a/.github/workflows/run-framework-tests.yml +++ b/.github/workflows/run-framework-tests.yml @@ -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 }} diff --git a/.github/workflows/update-framework-dep.yml b/.github/workflows/update-framework-dep.yml index 1f93441..a59ff30 100644 --- a/.github/workflows/update-framework-dep.yml +++ b/.github/workflows/update-framework-dep.yml @@ -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: diff --git a/README.md b/README.md index c4be27b..14f224f 100644 --- a/README.md +++ b/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 @@ ------- [![Go Report Card](https://goreportcard.com/badge/github.com/maddalax/htmgo)](https://goreportcard.com/report/github.com/maddalax/htmgo) ![Build](https://github.com/maddalax/htmgo/actions/workflows/run-framework-tests.yml/badge.svg) +[![Go Reference](https://pkg.go.dev/badge/github.com/maddalax/htmgo/framework@v1.0.2/h.svg)](https://htmgo.dev/docs) +[![codecov](https://codecov.io/github/maddalax/htmgo/graph/badge.svg?token=ANPD11LSGN)](https://codecov.io/github/maddalax/htmgo) +[![Join Discord](https://img.shields.io/badge/Join%20Discord-gray?style=flat&logo=discord&logoColor=white&link=https://htmgo.dev/discord)](https://htmgo.dev/discord) +looking for a python version? check out: https://fastht.ml + **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:** diff --git a/cli/htmgo/go.mod b/cli/htmgo/go.mod index ba191b9..c7e9d76 100644 --- a/cli/htmgo/go.mod +++ b/cli/htmgo/go.mod @@ -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 +) diff --git a/cli/htmgo/go.sum b/cli/htmgo/go.sum index 87e7902..447a06c 100644 --- a/cli/htmgo/go.sum +++ b/cli/htmgo/go.sum @@ -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= diff --git a/cli/htmgo/internal/dirutil/glob.go b/cli/htmgo/internal/dirutil/glob.go index 1315c66..dd0f3b3 100644 --- a/cli/htmgo/internal/dirutil/glob.go +++ b/cli/htmgo/internal/dirutil/glob.go @@ -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 diff --git a/cli/htmgo/runner.go b/cli/htmgo/runner.go index ab27c44..3f799a8 100644 --- a/cli/htmgo/runner.go +++ b/cli/htmgo/runner.go @@ -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 ")) + 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 := "" diff --git a/cli/htmgo/tasks/astgen/entry.go b/cli/htmgo/tasks/astgen/entry.go index e11ef0b..d53dc85 100644 --- a/cli/htmgo/tasks/astgen/entry.go +++ b/cli/htmgo/tasks/astgen/entry.go @@ -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 { diff --git a/cli/htmgo/tasks/astgen/map.go b/cli/htmgo/tasks/astgen/map.go deleted file mode 100644 index 201ccea..0000000 --- a/cli/htmgo/tasks/astgen/map.go +++ /dev/null @@ -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 - } - } - } -} diff --git a/cli/htmgo/tasks/astgen/util.go b/cli/htmgo/tasks/astgen/util.go index e55c5a9..af084fc 100644 --- a/cli/htmgo/tasks/astgen/util.go +++ b/cli/htmgo/tasks/astgen/util.go @@ -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 -} diff --git a/cli/htmgo/tasks/formatter/formatter.go b/cli/htmgo/tasks/formatter/formatter.go new file mode 100644 index 0000000..61a3205 --- /dev/null +++ b/cli/htmgo/tasks/formatter/formatter.go @@ -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 +} diff --git a/cli/htmgo/tasks/process/pid_windows.go b/cli/htmgo/tasks/process/pid_windows.go index 7036a92..44dd7fa 100644 --- a/cli/htmgo/tasks/process/pid_windows.go +++ b/cli/htmgo/tasks/process/pid_windows.go @@ -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 } diff --git a/cli/htmgo/tasks/process/process.go b/cli/htmgo/tasks/process/process.go index a31cd19..f3c1c41 100644 --- a/cli/htmgo/tasks/process/process.go +++ b/cli/htmgo/tasks/process/process.go @@ -115,7 +115,7 @@ func OnShutdown() { } } // give it a second - time.Sleep(time.Second * 2) + time.Sleep(time.Second * 1) // force kill KillAll() } diff --git a/cli/htmgo/tasks/run/build.go b/cli/htmgo/tasks/run/build.go index e468072..2f2f10b 100644 --- a/cli/htmgo/tasks/run/build.go +++ b/cli/htmgo/tasks/run/build.go @@ -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")) diff --git a/cli/htmgo/tasks/run/setup.go b/cli/htmgo/tasks/run/setup.go index d48d42d..0dd27d6 100644 --- a/cli/htmgo/tasks/run/setup.go +++ b/cli/htmgo/tasks/run/setup.go @@ -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() } diff --git a/cli/htmgo/tasks/util/file.go b/cli/htmgo/tasks/util/file.go index 3cb9f70..5ddd06c 100644 --- a/cli/htmgo/tasks/util/file.go +++ b/cli/htmgo/tasks/util/file.go @@ -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 diff --git a/examples/chat/chat/component.go b/examples/chat/chat/component.go index f81adbf..0840fe3 100644 --- a/examples/chat/chat/component.go +++ b/examples/chat/chat/component.go @@ -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), + ), ), ), ) diff --git a/examples/chat/components/button.go b/examples/chat/components/button.go index bc767c8..3f9c8c1 100644 --- a/examples/chat/components/button.go +++ b/examples/chat/components/button.go @@ -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, ) diff --git a/examples/chat/components/error.go b/examples/chat/components/error.go index a20ba0d..4b147dd 100644 --- a/examples/chat/components/error.go +++ b/examples/chat/components/error.go @@ -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"), + ), ) } diff --git a/examples/chat/components/input.go b/examples/chat/components/input.go index fec1363..0013d4e 100644 --- a/examples/chat/components/input.go +++ b/examples/chat/components/input.go @@ -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"), diff --git a/examples/chat/go.mod b/examples/chat/go.mod index 4add787..bd3dc37 100644 --- a/examples/chat/go.mod +++ b/examples/chat/go.mod @@ -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 ) diff --git a/examples/chat/go.sum b/examples/chat/go.sum index c07b1b7..25eba4a 100644 --- a/examples/chat/go.sum +++ b/examples/chat/go.sum @@ -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= diff --git a/examples/chat/pages/chat.$id.go b/examples/chat/pages/chat.$id.go index 493a873..deb676d 100644 --- a/examples/chat/pages/chat.$id.go +++ b/examples/chat/pages/chat.$id.go @@ -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"), diff --git a/examples/chat/pages/index.go b/examples/chat/pages/index.go index 571d5b5..229c7d9 100644 --- a/examples/chat/pages/index.go +++ b/examples/chat/pages/index.go @@ -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", diff --git a/examples/hackernews/go.mod b/examples/hackernews/go.mod index 8b1ea31..8df09b7 100644 --- a/examples/hackernews/go.mod +++ b/examples/hackernews/go.mod @@ -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 ) diff --git a/examples/hackernews/go.sum b/examples/hackernews/go.sum index 531d9d1..215e6ef 100644 --- a/examples/hackernews/go.sum +++ b/examples/hackernews/go.sum @@ -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= diff --git a/examples/hackernews/internal/news/news.go b/examples/hackernews/internal/news/news.go index 1b0c778..cd89762 100644 --- a/examples/hackernews/internal/news/news.go +++ b/examples/hackernews/internal/news/news.go @@ -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 } diff --git a/examples/hackernews/internal/sanitize/sanitize.go b/examples/hackernews/internal/sanitize/sanitize.go new file mode 100644 index 0000000..83aec98 --- /dev/null +++ b/examples/hackernews/internal/sanitize/sanitize.go @@ -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) +} diff --git a/examples/hackernews/pages/root.go b/examples/hackernews/pages/root.go index 1df43ef..3358b22 100644 --- a/examples/hackernews/pages/root.go +++ b/examples/hackernews/pages/root.go @@ -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"), diff --git a/examples/hackernews/partials/comments.go b/examples/hackernews/partials/comments.go index c0583fe..0a102ae 100644 --- a/examples/hackernews/partials/comments.go +++ b/examples/hackernews/partials/comments.go @@ -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), - ) - }, - )), ) } diff --git a/examples/hackernews/partials/sidebar.go b/examples/hackernews/partials/sidebar.go index c850d09..187a06c 100644 --- a/examples/hackernews/partials/sidebar.go +++ b/examples/hackernews/partials/sidebar.go @@ -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"), diff --git a/examples/hackernews/partials/story.go b/examples/hackernews/partials/story.go index 8b72272..a4b0f10 100644 --- a/examples/hackernews/partials/story.go +++ b/examples/hackernews/partials/story.go @@ -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"), diff --git a/examples/simple-auth/.dockerignore b/examples/simple-auth/.dockerignore new file mode 100644 index 0000000..fb47686 --- /dev/null +++ b/examples/simple-auth/.dockerignore @@ -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 \ No newline at end of file diff --git a/examples/simple-auth/.gitignore b/examples/simple-auth/.gitignore new file mode 100644 index 0000000..3d6a979 --- /dev/null +++ b/examples/simple-auth/.gitignore @@ -0,0 +1,6 @@ +/assets/dist +tmp +node_modules +.idea +__htmgo +dist \ No newline at end of file diff --git a/examples/simple-auth/Dockerfile b/examples/simple-auth/Dockerfile new file mode 100644 index 0000000..345375d --- /dev/null +++ b/examples/simple-auth/Dockerfile @@ -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"] diff --git a/examples/simple-auth/Taskfile.yml b/examples/simple-auth/Taskfile.yml new file mode 100644 index 0000000..28f1902 --- /dev/null +++ b/examples/simple-auth/Taskfile.yml @@ -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 diff --git a/examples/simple-auth/assets.go b/examples/simple-auth/assets.go new file mode 100644 index 0000000..9a76f11 --- /dev/null +++ b/examples/simple-auth/assets.go @@ -0,0 +1,13 @@ +//go:build !prod +// +build !prod + +package main + +import ( + "io/fs" + "simpleauth/internal/embedded" +) + +func GetStaticAssets() fs.FS { + return embedded.NewOsFs() +} diff --git a/examples/simple-auth/assets/css/input.css b/examples/simple-auth/assets/css/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/examples/simple-auth/assets/css/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/simple-auth/assets/public/apple-touch-icon.png b/examples/simple-auth/assets/public/apple-touch-icon.png new file mode 100644 index 0000000..d10e9fe Binary files /dev/null and b/examples/simple-auth/assets/public/apple-touch-icon.png differ diff --git a/examples/simple-auth/assets/public/favicon.ico b/examples/simple-auth/assets/public/favicon.ico new file mode 100644 index 0000000..040cccf Binary files /dev/null and b/examples/simple-auth/assets/public/favicon.ico differ diff --git a/htmgo-site/assets/public/icon-192-maskable.png b/examples/simple-auth/assets/public/icon-192-maskable.png similarity index 100% rename from htmgo-site/assets/public/icon-192-maskable.png rename to examples/simple-auth/assets/public/icon-192-maskable.png diff --git a/htmgo-site/assets/public/icon-192.png b/examples/simple-auth/assets/public/icon-192.png similarity index 100% rename from htmgo-site/assets/public/icon-192.png rename to examples/simple-auth/assets/public/icon-192.png diff --git a/htmgo-site/assets/public/icon-512-maskable.png b/examples/simple-auth/assets/public/icon-512-maskable.png similarity index 100% rename from htmgo-site/assets/public/icon-512-maskable.png rename to examples/simple-auth/assets/public/icon-512-maskable.png diff --git a/htmgo-site/assets/public/icon-512.png b/examples/simple-auth/assets/public/icon-512.png similarity index 100% rename from htmgo-site/assets/public/icon-512.png rename to examples/simple-auth/assets/public/icon-512.png diff --git a/examples/simple-auth/assets_prod.go b/examples/simple-auth/assets_prod.go new file mode 100644 index 0000000..f0598e1 --- /dev/null +++ b/examples/simple-auth/assets_prod.go @@ -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 +} diff --git a/examples/simple-auth/go.mod b/examples/simple-auth/go.mod new file mode 100644 index 0000000..b4e7c54 --- /dev/null +++ b/examples/simple-auth/go.mod @@ -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 +) diff --git a/examples/simple-auth/go.sum b/examples/simple-auth/go.sum new file mode 100644 index 0000000..1617650 --- /dev/null +++ b/examples/simple-auth/go.sum @@ -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= diff --git a/examples/simple-auth/htmgo.yml b/examples/simple-auth/htmgo.yml new file mode 100644 index 0000000..d60d2ff --- /dev/null +++ b/examples/simple-auth/htmgo.yml @@ -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"] diff --git a/examples/simple-auth/internal/db/db.go b/examples/simple-auth/internal/db/db.go new file mode 100644 index 0000000..41b7a34 --- /dev/null +++ b/examples/simple-auth/internal/db/db.go @@ -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, + } +} diff --git a/examples/simple-auth/internal/db/models.go b/examples/simple-auth/internal/db/models.go new file mode 100644 index 0000000..a63cbae --- /dev/null +++ b/examples/simple-auth/internal/db/models.go @@ -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 +} diff --git a/examples/simple-auth/internal/db/provider.go b/examples/simple-auth/internal/db/provider.go new file mode 100644 index 0000000..8bc1693 --- /dev/null +++ b/examples/simple-auth/internal/db/provider.go @@ -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) +} diff --git a/examples/simple-auth/internal/db/queries.sql b/examples/simple-auth/internal/db/queries.sql new file mode 100644 index 0000000..e96497e --- /dev/null +++ b/examples/simple-auth/internal/db/queries.sql @@ -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 = ?; diff --git a/examples/simple-auth/internal/db/queries.sql.go b/examples/simple-auth/internal/db/queries.sql.go new file mode 100644 index 0000000..eee80ac --- /dev/null +++ b/examples/simple-auth/internal/db/queries.sql.go @@ -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 +} diff --git a/examples/simple-auth/internal/db/schema.sql b/examples/simple-auth/internal/db/schema.sql new file mode 100644 index 0000000..e7b53a9 --- /dev/null +++ b/examples/simple-auth/internal/db/schema.sql @@ -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); diff --git a/examples/simple-auth/internal/embedded/os.go b/examples/simple-auth/internal/embedded/os.go new file mode 100644 index 0000000..ddfd55f --- /dev/null +++ b/examples/simple-auth/internal/embedded/os.go @@ -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{} +} diff --git a/examples/simple-auth/internal/user/handler.go b/examples/simple-auth/internal/user/handler.go new file mode 100644 index 0000000..0ff3a97 --- /dev/null +++ b/examples/simple-auth/internal/user/handler.go @@ -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 +} diff --git a/examples/simple-auth/internal/user/http.go b/examples/simple-auth/internal/user/http.go new file mode 100644 index 0000000..8e1f462 --- /dev/null +++ b/examples/simple-auth/internal/user/http.go @@ -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 +} diff --git a/examples/simple-auth/internal/user/password.go b/examples/simple-auth/internal/user/password.go new file mode 100644 index 0000000..fd6a6a7 --- /dev/null +++ b/examples/simple-auth/internal/user/password.go @@ -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 +} diff --git a/examples/simple-auth/internal/user/session.go b/examples/simple-auth/internal/user/session.go new file mode 100644 index 0000000..19cd5f8 --- /dev/null +++ b/examples/simple-auth/internal/user/session.go @@ -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 +} diff --git a/examples/simple-auth/main.go b/examples/simple-auth/main.go new file mode 100644 index 0000000..ca237d4 --- /dev/null +++ b/examples/simple-auth/main.go @@ -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) + }, + }) +} diff --git a/examples/simple-auth/pages/index.go b/examples/simple-auth/pages/index.go new file mode 100644 index 0000000..f159adc --- /dev/null +++ b/examples/simple-auth/pages/index.go @@ -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"), + ), + ) +} diff --git a/examples/simple-auth/pages/login.go b/examples/simple-auth/pages/login.go new file mode 100644 index 0000000..a9b148d --- /dev/null +++ b/examples/simple-auth/pages/login.go @@ -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"), + ), + }, + }), + ), + ) +} diff --git a/examples/simple-auth/pages/logout.go b/examples/simple-auth/pages/logout.go new file mode 100644 index 0000000..3655a42 --- /dev/null +++ b/examples/simple-auth/pages/logout.go @@ -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 +} diff --git a/examples/simple-auth/pages/register.go b/examples/simple-auth/pages/register.go new file mode 100644 index 0000000..476c180 --- /dev/null +++ b/examples/simple-auth/pages/register.go @@ -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"), + ), + }, + }), + ), + ) +} diff --git a/examples/simple-auth/pages/root.go b/examples/simple-auth/pages/root.go new file mode 100644 index 0000000..510163e --- /dev/null +++ b/examples/simple-auth/pages/root.go @@ -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...), + ), + ), + ) +} diff --git a/examples/simple-auth/partials/profile.go b/examples/simple-auth/partials/profile.go new file mode 100644 index 0000000..8f18d2f --- /dev/null +++ b/examples/simple-auth/partials/profile.go @@ -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("/") +} diff --git a/examples/simple-auth/partials/user.go b/examples/simple-auth/partials/user.go new file mode 100644 index 0000000..1023e6f --- /dev/null +++ b/examples/simple-auth/partials/user.go @@ -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("/") +} diff --git a/examples/simple-auth/sqlc.yaml b/examples/simple-auth/sqlc.yaml new file mode 100644 index 0000000..30c0518 --- /dev/null +++ b/examples/simple-auth/sqlc.yaml @@ -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" diff --git a/examples/simple-auth/tailwind.config.js b/examples/simple-auth/tailwind.config.js new file mode 100644 index 0000000..b18125c --- /dev/null +++ b/examples/simple-auth/tailwind.config.js @@ -0,0 +1,5 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["**/*.go"], + plugins: [], +}; diff --git a/examples/simple-auth/ui/button.go b/examples/simple-auth/ui/button.go new file mode 100644 index 0000000..015a5e0 --- /dev/null +++ b/examples/simple-auth/ui/button.go @@ -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"), + ) +} diff --git a/examples/simple-auth/ui/error.go b/examples/simple-auth/ui/error.go new file mode 100644 index 0000000..47d1eac --- /dev/null +++ b/examples/simple-auth/ui/error.go @@ -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), + ) +} diff --git a/examples/simple-auth/ui/input.go b/examples/simple-auth/ui/input.go new file mode 100644 index 0000000..6e302fb --- /dev/null +++ b/examples/simple-auth/ui/input.go @@ -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 +} diff --git a/examples/simple-auth/ui/login.go b/examples/simple-auth/ui/login.go new file mode 100644 index 0000000..50cf046 --- /dev/null +++ b/examples/simple-auth/ui/login.go @@ -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), + ), + ), + ) +} diff --git a/examples/todo-list/go.mod b/examples/todo-list/go.mod index bde8528..d6f2727 100644 --- a/examples/todo-list/go.mod +++ b/examples/todo-list/go.mod @@ -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 ) diff --git a/examples/todo-list/go.sum b/examples/todo-list/go.sum index 34c7ebc..3bec236 100644 --- a/examples/todo-list/go.sum +++ b/examples/todo-list/go.sum @@ -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= diff --git a/examples/todo-list/pages/base/root.go b/examples/todo-list/pages/base/root.go index 7a9ca91..f612a62 100644 --- a/examples/todo-list/pages/base/root.go +++ b/examples/todo-list/pages/base/root.go @@ -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"), diff --git a/examples/todo-list/pages/index.go b/examples/todo-list/pages/index.go index 65b5cac..2cc736d 100644 --- a/examples/todo-list/pages/index.go +++ b/examples/todo-list/pages/index.go @@ -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"), + ), ), ), ), diff --git a/examples/todo-list/partials/task/task.go b/examples/todo-list/partials/task/task.go index 29498ab..f307a57 100644 --- a/examples/todo-list/partials/task/task.go +++ b/examples/todo-list/partials/task/task.go @@ -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(` - `)), + `), + ), ), ) } @@ -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), ) diff --git a/framework-ui/go.mod b/framework-ui/go.mod index b84f8e1..2645bbc 100644 --- a/framework-ui/go.mod +++ b/framework-ui/go.mod @@ -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 diff --git a/framework-ui/go.sum b/framework-ui/go.sum index 531d9d1..210b60a 100644 --- a/framework-ui/go.sum +++ b/framework-ui/go.sum @@ -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= diff --git a/framework/assets/js/htmgo.ts b/framework/assets/js/htmgo.ts index 5eac806..105b1a3 100644 --- a/framework/assets/js/htmgo.ts +++ b/framework/assets/js/htmgo.ts @@ -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; + } + } +}); diff --git a/framework/assets/js/htmxextensions/mutation-error.ts b/framework/assets/js/htmxextensions/mutation-error.ts index caf9af1..99b16cc 100644 --- a/framework/assets/js/htmxextensions/mutation-error.ts +++ b/framework/assets/js/htmxextensions/mutation-error.ts @@ -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 }); + } }); } } diff --git a/framework/config/project.go b/framework/config/project.go index 72e222d..659d364 100644 --- a/framework/config/project.go +++ b/framework/config/project.go @@ -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() } } } diff --git a/framework/config/project_test.go b/framework/config/project_test.go index f015635..89288ae 100644 --- a/framework/config/project_test.go +++ b/framework/config/project_test.go @@ -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) diff --git a/htmgo-site/internal/datastructures/map.go b/framework/datastructure/orderedmap/orderedmap.go similarity index 63% rename from htmgo-site/internal/datastructures/map.go rename to framework/datastructure/orderedmap/orderedmap.go index 533ca4f..c356b68 100644 --- a/htmgo-site/internal/datastructures/map.go +++ b/framework/datastructure/orderedmap/orderedmap.go @@ -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) diff --git a/framework/datastructure/orderedmap/orderedmap_test.go b/framework/datastructure/orderedmap/orderedmap_test.go new file mode 100644 index 0000000..c4dca24 --- /dev/null +++ b/framework/datastructure/orderedmap/orderedmap_test.go @@ -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) +} diff --git a/framework/h/app.go b/framework/h/app.go index f32c560..3c095a5 100644 --- a/framework/h/app.go +++ b/framework/h/app.go @@ -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) diff --git a/framework/h/array.go b/framework/h/array.go index 94287df..7cf5529 100644 --- a/framework/h/array.go +++ b/framework/h/array.go @@ -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 { diff --git a/framework/h/array_test.go b/framework/h/array_test.go new file mode 100644 index 0000000..42d5132 --- /dev/null +++ b/framework/h/array_test.go @@ -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) +} diff --git a/framework/h/attribute.go b/framework/h/attribute.go index e62ceeb..daab17f 100644 --- a/framework/h/attribute.go +++ b/framework/h/attribute.go @@ -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] diff --git a/framework/h/attribute_test.go b/framework/h/attribute_test.go new file mode 100644 index 0000000..c2a56f1 --- /dev/null +++ b/framework/h/attribute_test.go @@ -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) +} diff --git a/framework/h/base.go b/framework/h/base.go index bbb8006..f711dd9 100644 --- a/framework/h/base.go +++ b/framework/h/base.go @@ -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 { diff --git a/framework/h/base_test.go b/framework/h/base_test.go new file mode 100644 index 0000000..48540a3 --- /dev/null +++ b/framework/h/base_test.go @@ -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)) +} diff --git a/framework/h/cache.go b/framework/h/cache.go index 92338cb..9709281 100644 --- a/framework/h/cache.go +++ b/framework/h/cache.go @@ -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() diff --git a/framework/h/command_test.go b/framework/h/command_test.go index 0da8afb..2442506 100644 --- a/framework/h/command_test.go +++ b/framework/h/command_test.go @@ -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';") +} diff --git a/framework/h/conditionals.go b/framework/h/conditionals.go index 7705b26..e0b4d0b 100644 --- a/framework/h/conditionals.go +++ b/framework/h/conditionals.go @@ -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) diff --git a/framework/h/conditionals_test.go b/framework/h/conditionals_test.go new file mode 100644 index 0000000..6b618fb --- /dev/null +++ b/framework/h/conditionals_test.go @@ -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, "

hello

", 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, "

hello

", Render(result)) + + result2 := IfElse(false, Pf("hello"), Pf("world")) + assert.Equal(t, "

world

", Render(result2)) +} + +func TestTernary(t *testing.T) { + t.Parallel() + result := Ternary(true, Pf("hello"), Pf("world")) + assert.Equal(t, "

hello

", Render(result)) + + result2 := Ternary(false, Pf("hello"), Pf("world")) + assert.Equal(t, "

world

", 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, "

hello

", Render(result)) + + result2 := IfElseLazy(false, func() *Element { return Pf("hello") }, func() *Element { return Pf("world") }) + assert.Equal(t, "

world

", Render(result2)) +} + +func TestElementIf(t *testing.T) { + t.Parallel() + element := Pf("hello") + result := ElementIf(true, element) + assert.Equal(t, "

hello

", 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, "

hello

", Render(result)) + + result2 := IfElseE(false, element1, element2) + assert.Equal(t, "

world

", Render(result2)) +} + +func TestIfHtmxRequest(t *testing.T) { + t.Parallel() + ctx := &RequestContext{isHxRequest: true} + result := IfHtmxRequest(ctx, Pf("hello")) + assert.Equal(t, "

hello

", 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 +} diff --git a/framework/h/extensions_test.go b/framework/h/extensions_test.go new file mode 100644 index 0000000..0458c66 --- /dev/null +++ b/framework/h/extensions_test.go @@ -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) +} diff --git a/framework/h/header_test.go b/framework/h/header_test.go new file mode 100644 index 0000000..5715a2e --- /dev/null +++ b/framework/h/header_test.go @@ -0,0 +1,57 @@ +package h + +import ( + "github.com/maddalax/htmgo/framework/hx" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReplaceUrlHeader(t *testing.T) { + headers := ReplaceUrlHeader("/new-url") + assert.Contains(t, *headers, hx.ReplaceUrlHeader) + assert.Equal(t, "/new-url", (*headers)[hx.ReplaceUrlHeader]) +} + +func TestPushUrlHeader(t *testing.T) { + headers := PushUrlHeader("/push-url") + assert.Contains(t, *headers, hx.PushUrlHeader) + assert.Equal(t, "/push-url", (*headers)[hx.PushUrlHeader]) +} + +func TestPushQsHeader(t *testing.T) { + ctx := &RequestContext{currentBrowserUrl: "https://example.com/path"} + qs := NewQs("a", "b", "c", "d") + headers := PushQsHeader(ctx, qs) + expectedURL := "/path?a=b&c=d" + assert.Contains(t, *headers, hx.ReplaceUrlHeader) + assert.Equal(t, expectedURL, (*headers)[hx.ReplaceUrlHeader]) +} + +func TestCombineHeaders(t *testing.T) { + h1 := NewHeaders("Content-Type", "application/json") + h2 := NewHeaders("Authorization", "Bearer token") + combined := CombineHeaders(h1, h2) + assert.Equal(t, "application/json", (*combined)["Content-Type"]) + assert.Equal(t, "Bearer token", (*combined)["Authorization"]) +} + +func TestCurrentPath(t *testing.T) { + req, _ := http.NewRequest("GET", "https://example.com", nil) + req.Header.Set(hx.CurrentUrlHeader, "https://example.com/current-path") + ctx := &RequestContext{Request: req} + path := CurrentPath(ctx) + assert.Equal(t, "/current-path", path) +} + +func TestNewHeaders(t *testing.T) { + headers := NewHeaders("X-Custom", "value", "X-Another", "another-value") + require.NotNil(t, headers) + assert.Equal(t, "value", (*headers)["X-Custom"]) + assert.Equal(t, "another-value", (*headers)["X-Another"]) + + invalidHeaders := NewHeaders("X-Custom") + assert.Empty(t, *invalidHeaders) // Check incorrect pair length handling +} diff --git a/framework/h/lifecycle.go b/framework/h/lifecycle.go index c9ceb7d..1aed70b 100644 --- a/framework/h/lifecycle.go +++ b/framework/h/lifecycle.go @@ -2,6 +2,7 @@ package h import ( "fmt" + "github.com/google/uuid" "github.com/maddalax/htmgo/framework/hx" "github.com/maddalax/htmgo/framework/internal/util" "strings" @@ -30,7 +31,6 @@ func validateCommands(cmds []Command) { panic(fmt.Sprintf("element is not allowed in lifecycle events. Got: %v", t)) default: panic(fmt.Sprintf("type is not allowed in lifecycle events. Got: %v", t)) - } } } @@ -51,7 +51,8 @@ func (l *LifeCycle) OnEvent(event hx.Event, cmd ...Command) *LifeCycle { return l } -// OnLoad This will work on any element because of the htmgo htmx extension to trigger it, instead of the browser. +// OnLoad executes the given commands when the element is loaded into the DOM, it also executes when the element is replaced / swapped in. +// This will work on any element because of the htmgo htmx extension to trigger it, instead of the browser. func OnLoad(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.LoadDomEvent, cmd...) } @@ -61,58 +62,73 @@ func (l *LifeCycle) HxBeforeRequest(cmd ...Command) *LifeCycle { return l } +// HxOnLoad executes the given commands when the element is loaded into the DOM. +// Deprecated: Use OnLoad instead. func HxOnLoad(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.LoadEvent, cmd...) } +// HxOnAfterSwap executes the given commands when the element is swapped in. func HxOnAfterSwap(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.AfterSwapEvent, cmd...) } +// OnClick executes the given commands when the element is clicked. func OnClick(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.ClickEvent, cmd...) } +// OnEvent executes the given commands when the given event is triggered. func OnEvent(event hx.Event, cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(event, cmd...) } +// HxBeforeSseMessage executes the given commands when a message is received from the server via SSE, but before it is processed. func HxBeforeSseMessage(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.SseBeforeMessageEvent, cmd...) } +// HxAfterSseMessage executes the given commands when a message is received from the server via SSE, and after it is processed. func HxAfterSseMessage(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.SseAfterMessageEvent, cmd...) } +// OnSubmit executes the given commands when the form is submitted. func OnSubmit(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.SubmitEvent, cmd...) } +// HxOnSseError executes the given commands when an error occurs while connecting to the server via SSE. func HxOnSseError(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.SseErrorEvent, cmd...) } +// HxOnSseClose executes the given commands when the connection to the server via SSE is closed. func HxOnSseClose(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.SseClosedEvent, cmd...) } +// HxOnSseConnecting executes the given commands when the connection to the server via SSE is being established. func HxOnSseConnecting(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.SseConnectingEvent, cmd...) } +// HxOnSseOpen executes the given commands when the connection to the server via SSE is established. func HxOnSseOpen(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.SseConnectedEvent, cmd...) } +// HxBeforeRequest executes the given commands before the request is sent. func HxBeforeRequest(cmd ...Command) *LifeCycle { return NewLifeCycle().HxBeforeRequest(cmd...) } +// HxAfterRequest executes the given commands after the request is sent. func HxAfterRequest(cmd ...Command) *LifeCycle { return NewLifeCycle().HxAfterRequest(cmd...) } +// HxOnMutationError executes the given commands when a mutation error of a request occurs. func HxOnMutationError(cmd ...Command) *LifeCycle { return NewLifeCycle().HxOnMutationError(cmd...) } @@ -138,16 +154,19 @@ type ComplexJsCommand struct { TempFuncName string } +// NewComplexJsCommand creates a new complex JavaScript command. func NewComplexJsCommand(command string) ComplexJsCommand { name := fmt.Sprintf("__eval_%s", util.RandSeq(6)) return ComplexJsCommand{Command: command, TempFuncName: name} } +// SetText sets the inner text of the element. func SetText(text string) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.innerText = '%s'", text)} } +// SetTextOnChildren sets the inner text of all the children of the element that match the selector. func SetTextOnChildren(selector, text string) ComplexJsCommand { // language=JavaScript return EvalJs(fmt.Sprintf(` @@ -158,26 +177,31 @@ func SetTextOnChildren(selector, text string) ComplexJsCommand { `, selector, text)) } +// Increment increments the inner text of the element by the given amount. func Increment(amount int) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.innerText = parseInt(this.innerText) + %d", amount)} } +// SetInnerHtml sets the inner HTML of the element. func SetInnerHtml(r Ren) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.innerHTML = `%s`", Render(r))} } +// SetOuterHtml sets the outer HTML of the element. func SetOuterHtml(r Ren) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.outerHTML = `%s`", Render(r))} } +// AddAttribute adds the given attribute to the element. func AddAttribute(name, value string) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.setAttribute('%s', '%s')", name, value)} } +// SetDisabled sets the disabled attribute on the element. func SetDisabled(disabled bool) SimpleJsCommand { if disabled { return AddAttribute("disabled", "true") @@ -186,26 +210,79 @@ func SetDisabled(disabled bool) SimpleJsCommand { } } +// RemoveAttribute removes the given attribute from the element. func RemoveAttribute(name string) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.removeAttribute('%s')", name)} } +// AddClass adds the given class to the element. func AddClass(class string) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.classList.add('%s')", class)} } +// RemoveClass removes the given class from the element. func RemoveClass(class string) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.classList.remove('%s')", class)} } +// ToggleClass toggles the given class on the element. func ToggleClass(class string) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.classList.toggle('%s')", class)} } +// ToggleText toggles the given text on the element. +func ToggleText(text string, textTwo string) Command { + // language=JavaScript + return EvalJs(fmt.Sprintf(` + if(self.innerText === "%s") { + self.innerText = "%s"; + } else { + self.innerText = "%s"; + } + `, text, textTwo, text)) +} + +// ToggleTextOnSibling toggles the given text on the siblings of the element. +func ToggleTextOnSibling(selector, text string, textTwo string) Command { + // language=JavaScript + return EvalJsOnSibling(selector, fmt.Sprintf(` + if(element.innerText === "%s") { + element.innerText = "%s"; + } else { + element.innerText = "%s"; + } + `, text, textTwo, text)) +} + +// ToggleTextOnChildren toggles the given text on the children of the element. +func ToggleTextOnChildren(selector, text string, textTwo string) Command { + // language=JavaScript + return EvalJsOnChildren(selector, fmt.Sprintf(` + if(element.innerText === "%s") { + element.innerText = "%s"; + } else { + element.innerText = "%s"; + } + `, text, textTwo, text)) +} + +// ToggleTextOnParent toggles the given text on the parent of the element. +func ToggleTextOnParent(text string, textTwo string) Command { + // language=JavaScript + return EvalJsOnParent(fmt.Sprintf(` + if(element.innerText === "%s") { + element.innerText = "%s"; + } else { + element.innerText = "%s"; + } + `, text, textTwo, text)) +} + +// ToggleClassOnElement toggles the given class on the elements returned by the selector. func ToggleClassOnElement(selector, class string) ComplexJsCommand { // language=JavaScript return EvalJs(fmt.Sprintf(` @@ -215,15 +292,18 @@ func ToggleClassOnElement(selector, class string) ComplexJsCommand { )) } +// EvalJsOnParent evaluates the given JavaScript code on the parent of the element. Reference the element using 'element'. func EvalJsOnParent(js string) ComplexJsCommand { // language=JavaScript return EvalJs(fmt.Sprintf(` - if(!self.parentElement) { return; } - let element = self.parentElement; - %s + if(self.parentElement) { + let element = self.parentElement; + %s + } `, js)) } +// EvalJsOnChildren evaluates the given JavaScript code on the children of the element. Reference the element using 'element'. func EvalJsOnChildren(selector, js string) ComplexJsCommand { // language=JavaScript return EvalJs(fmt.Sprintf(` @@ -234,77 +314,140 @@ func EvalJsOnChildren(selector, js string) ComplexJsCommand { `, selector, js)) } +// EvalJsOnSibling evaluates the given JavaScript code on the siblings of the element. Reference the element using 'element'. func EvalJsOnSibling(selector, js string) ComplexJsCommand { // language=JavaScript return EvalJs(fmt.Sprintf(` - if(!self.parentElement) { return; } - let siblings = self.parentElement.querySelectorAll('%s'); - siblings.forEach(function(element) { - %s - }); + if(self.parentElement) { + let siblings = self.parentElement.querySelectorAll('%s'); + siblings.forEach(function(element) { + %s + }); + } `, selector, js)) } +// SetClassOnParent sets the given class on the parent of the element. func SetClassOnParent(class string) ComplexJsCommand { // language=JavaScript return EvalJsOnParent(fmt.Sprintf("element.classList.add('%s')", class)) } +// RemoveClassOnParent removes the given class from the parent of the element. func RemoveClassOnParent(class string) ComplexJsCommand { // language=JavaScript return EvalJsOnParent(fmt.Sprintf("element.classList.remove('%s')", class)) } +// SetClassOnChildren sets the given class on the children of the element. func SetClassOnChildren(selector, class string) ComplexJsCommand { // language=JavaScript return EvalJsOnChildren(selector, fmt.Sprintf("element.classList.add('%s')", class)) } +// ToggleClassOnChildren toggles the given class on the children of the element. +func ToggleClassOnChildren(selector, class string) ComplexJsCommand { + // language=JavaScript + return EvalJsOnChildren(selector, fmt.Sprintf("element.classList.toggle('%s')", class)) +} + +// ToggleClassOnParent toggles the given class on the parent of the element. +func ToggleClassOnParent(class string) ComplexJsCommand { + // language=JavaScript + return EvalJsOnParent(fmt.Sprintf("element.classList.toggle('%s')", class)) +} + +// ToggleClassOnSibling toggles the given class on the siblings of the element. +func ToggleClassOnSibling(selector, class string) ComplexJsCommand { + // language=JavaScript + return EvalJsOnSibling(selector, fmt.Sprintf("element.classList.toggle('%s')", class)) +} + +// SetClassOnSibling sets the given class on the siblings of the element. Reference the element using 'element'. func SetClassOnSibling(selector, class string) ComplexJsCommand { // language=JavaScript return EvalJsOnSibling(selector, fmt.Sprintf("element.classList.add('%s')", class)) } +// RemoveClassOnSibling removes the given class from the siblings of the element. Reference the element using 'element'. func RemoveClassOnSibling(selector, class string) ComplexJsCommand { // language=JavaScript return EvalJsOnSibling(selector, fmt.Sprintf("element.classList.remove('%s')", class)) } +// RemoveClassOnChildren removes the given class from the children of the element. Reference the element using 'element'. func RemoveClassOnChildren(selector, class string) ComplexJsCommand { // language=JavaScript return EvalJsOnChildren(selector, fmt.Sprintf("element.classList.remove('%s')", class)) } +// Alert displays an alert dialog with the given text. func Alert(text string) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("alert('%s')", text)} } +// Remove removes the element from the DOM. func Remove() SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: "this.remove()"} } +// EvalJs evaluates the given JavaScript code. func EvalJs(js string) ComplexJsCommand { return NewComplexJsCommand(js) } +func EvalCommandsOnSelector(selector string, cmds ...Command) ComplexJsCommand { + lines := make([]string, len(cmds)) + for i, cmd := range cmds { + lines[i] = Render(cmd) + lines[i] = strings.ReplaceAll(lines[i], "this.", "self.") + // some commands set the element we need to fix it so we arent redeclaring it + lines[i] = strings.ReplaceAll(lines[i], "let element =", "element =") + } + code := strings.Join(lines, "\n") + return EvalJs(fmt.Sprintf(` + let element = document.querySelector("%s"); + + if(!element) { + return; + } + + self = element; + %s + `, selector, code)) +} + +func EvalCommands(element *Element, cmds ...Command) ComplexJsCommand { + id := strings.ReplaceAll(uuid.NewString(), "-", "") + element.AppendChildren( + Attribute("data-eval-commands-id", id), + ) + return EvalCommandsOnSelector( + fmt.Sprintf(`[data-eval-commands-id='%s']`, id), cmds...) +} + +// PreventDefault prevents the default action of the event. func PreventDefault() SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: "event.preventDefault()"} } +// ConsoleLog logs a message to the console. func ConsoleLog(text string) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("console.log('%s')", text)} } +// SetValue sets the value of the element. func SetValue(value string) SimpleJsCommand { // language=JavaScript return SimpleJsCommand{Command: fmt.Sprintf("this.value = '%s'", value)} } +// SubmitFormOnEnter submits the form when the user presses the enter key. func SubmitFormOnEnter() ComplexJsCommand { // language=JavaScript return EvalJs(` @@ -312,6 +455,7 @@ func SubmitFormOnEnter() ComplexJsCommand { `) } +// InjectScript injects a script tag into the document. func InjectScript(src string) ComplexJsCommand { // language=JavaScript return NewComplexJsCommand(fmt.Sprintf(` @@ -322,6 +466,7 @@ func InjectScript(src string) ComplexJsCommand { `, src)) } +// InjectScriptIfNotExist injects a script tag into the document if it does not already exist. func InjectScriptIfNotExist(src string) ComplexJsCommand { // language=JavaScript return EvalJs(fmt.Sprintf(` diff --git a/framework/h/qs.go b/framework/h/qs.go index 75fad83..11cd5bf 100644 --- a/framework/h/qs.go +++ b/framework/h/qs.go @@ -14,7 +14,7 @@ func NewQs(pairs ...string) *Qs { m: make(map[string]string), } if len(pairs)%2 != 0 { - return q + pairs = append(pairs, "") } for i := 0; i < len(pairs); i++ { q.m[pairs[i]] = pairs[i+1] @@ -38,8 +38,10 @@ func (q *Qs) ToString() string { index := 0 for k, v := range q.m { builder.WriteString(k) - builder.WriteString("=") - builder.WriteString(v) + if v != "" { + builder.WriteString("=") + builder.WriteString(v) + } if index < len(q.m)-1 { builder.WriteString("&") } @@ -48,6 +50,13 @@ func (q *Qs) ToString() string { return builder.String() } +// GetQueryParam returns the value of the given query parameter from the request URL. +// There are two layers of priority: +// 1. The query parameter in the URL +// 2. The current browser URL +// If the query parameter is not found in the URL from the *RequestContext, it will fall back to the current browser URL if set. +// The URL from the *RequestContext would normally be the url from an XHR request through htmx, +// which is not the current browser url a visitor may be on. func GetQueryParam(ctx *RequestContext, key string) string { value, ok := ctx.Request.URL.Query()[key] if value == nil || !ok { @@ -65,6 +74,11 @@ func GetQueryParam(ctx *RequestContext, key string) string { return value[0] } +// SetQueryParams sets the query parameters of the given URL. +// Given the *Qs passed in, it will set the query parameters of the URL to the given values. +// If the value does not exist in *QS, it will remain untouched. +// If the value is an empty string, it will be removed from the query parameters. +// If the value is not an empty string, it will be set to the given value. func SetQueryParams(href string, qs *Qs) string { u, err := url.Parse(href) if err != nil { diff --git a/framework/h/qs_test.go b/framework/h/qs_test.go new file mode 100644 index 0000000..22e80b1 --- /dev/null +++ b/framework/h/qs_test.go @@ -0,0 +1,76 @@ +package h + +import ( + "github.com/stretchr/testify/assert" + "net/http" + "net/url" + "testing" +) + +func assertHas(t *testing.T, qs *Qs, key string, value string) { + str := qs.ToString() + if value == "" { + assert.Contains(t, str, key) + assert.NotContains(t, str, key+"=") + } else { + assert.Contains(t, str, key+"="+value) + } +} + +func TestQs(t *testing.T) { + t.Parallel() + qs := NewQs("a", "b", "c") + assertHas(t, qs, "a", "b") + assertHas(t, qs, "c", "") + + qs2 := NewQs("a", "b", "c", "d") + assertHas(t, qs2, "a", "b") + assertHas(t, qs2, "c", "d") + + qs2.Add("e", "f") + assertHas(t, qs2, "a", "b") + assertHas(t, qs2, "c", "d") + assertHas(t, qs2, "e", "f") + + qs2.Remove("e") + assert.NotContains(t, qs2.ToString(), "e") +} + +func TestSetQsOnUrl(t *testing.T) { + t.Parallel() + qs := NewQs("a", "b", "c", "d") + set := SetQueryParams("https://example.com/path", qs) + assert.Equal(t, "https://example.com/path?a=b&c=d", set) +} + +func TestSetQsOnUrlWithDelete(t *testing.T) { + t.Parallel() + qs := NewQs("a", "b2", "c", "") + set := SetQueryParams("https://example.com/path?a=b&c=d", qs) + assert.Equal(t, "https://example.com/path?a=b2", set) +} + +func TestGetQueryParam(t *testing.T) { + t.Parallel() + req, _ := http.NewRequest("GET", "http://localhost/?foo=bar&baz=qux", nil) + ctx := &RequestContext{Request: req} + + result := GetQueryParam(ctx, "foo") + assert.Equal(t, "bar", result) + + result = GetQueryParam(ctx, "baz") + assert.Equal(t, "qux", result) + + result = GetQueryParam(ctx, "missing") + assert.Equal(t, "", result) + + ctx.currentBrowserUrl = "http://localhost/?current=value" + + result = GetQueryParam(ctx, "current") + assert.Equal(t, "value", result) + + // url params should override browser url + req.URL, _ = url.Parse("http://localhost/?foo=override") + result = GetQueryParam(ctx, "foo") + assert.Equal(t, "override", result) +} diff --git a/framework/h/render.go b/framework/h/render.go index aa37dd5..bad3b35 100644 --- a/framework/h/render.go +++ b/framework/h/render.go @@ -8,11 +8,34 @@ type Ren interface { Render(context *RenderContext) } -func Render(node Ren) string { +type RenderOptions struct { + doctype bool +} + +type RenderOpt func(context *RenderContext, opt *RenderOptions) + +func WithDocType() RenderOpt { + return func(context *RenderContext, opt *RenderOptions) { + opt.doctype = true + } +} + +// Render renders the given node recursively, and returns the resulting string. +func Render(node Ren, opts ...RenderOpt) string { builder := &strings.Builder{} context := &RenderContext{ builder: builder, } + options := &RenderOptions{} + + for _, opt := range opts { + opt(context, options) + } + + if options.doctype { + builder.WriteString("") + } + node.Render(context) return builder.String() } diff --git a/framework/h/render_test.go b/framework/h/render_test.go index dbbd1e1..83a4cf4 100644 --- a/framework/h/render_test.go +++ b/framework/h/render_test.go @@ -10,6 +10,14 @@ import ( "time" ) +func TestRendererShouldRenderDocType(t *testing.T) { + t.Parallel() + result := Render(Html( + Div(), + ), WithDocType()) + assert.Equal(t, `
`, result) +} + func TestSimpleRender(t *testing.T) { t.Parallel() result := Render( diff --git a/framework/h/renderer.go b/framework/h/renderer.go index 83efa4f..b0969a9 100644 --- a/framework/h/renderer.go +++ b/framework/h/renderer.go @@ -35,9 +35,15 @@ var voidTags = map[string]bool{ "wbr": true, } +type ScriptEntry struct { + Body string + ChildOf *Element +} + type RenderContext struct { - builder *strings.Builder - scripts []string + builder *strings.Builder + scripts []ScriptEntry + currentElement *Element } func (ctx *RenderContext) AddScript(funcName string, body string) { @@ -48,7 +54,11 @@ func (ctx *RenderContext) AddScript(funcName string, body string) { %s } `, funcName, funcName, body) - ctx.scripts = append(ctx.scripts, script) + + ctx.scripts = append(ctx.scripts, ScriptEntry{ + Body: script, + ChildOf: ctx.currentElement, + }) } func (node *Element) Render(context *RenderContext) { @@ -56,6 +66,8 @@ func (node *Element) Render(context *RenderContext) { return } + context.currentElement = node + if node.tag == CachedNodeTag { meta := node.meta.(*CachedNode) meta.Render(context) @@ -147,7 +159,7 @@ func (node *Element) Render(context *RenderContext) { } if node.tag != "" { - renderScripts(context) + renderScripts(context, node) if !voidTags[node.tag] { context.builder.WriteString("
  • hello
  • world
  • `, Render(list)) +} + +func TestIterMap(t *testing.T) { + t.Parallel() + items := map[string]string{ + "hello": "world", + } + list := Ul(IterMap(items, func(key string, value string) *Element { + return Li(Text(key), Text(value)) + })) + assert.Equal(t, ``, Render(list)) +} + +func TestUnsafeRaw(t *testing.T) { + t.Parallel() + element := UnsafeRaw("
    Hello World
    ") + assert.Equal(t, "
    Hello World
    ", Render(element)) +} + +func TestUnsafeRawScript(t *testing.T) { + t.Parallel() + element := UnsafeRawScript("alert('Hello World')") + assert.Equal(t, "", Render(element)) +} + +func TestUnsafeRawF(t *testing.T) { + t.Parallel() + element := UnsafeRawF("Hello %s", "World") + assert.Equal(t, "Hello World", Render(element)) +} + +func TestMultiLineQuotes(t *testing.T) { + t.Parallel() + element := MultiLineQuotes("Hello World") + assert.Equal(t, "`Hello World`", element) +} + +func TestValue(t *testing.T) { + t.Parallel() + assert.Equal(t, ` value="Hello World"`, Render(Value("Hello World"))) + assert.Equal(t, ` value="1"`, Render(Value(1))) + assert.Equal(t, ` value="true"`, Render(Value(true))) +} + +func TestAppendChildren(t *testing.T) { + t.Parallel() + element := Div() + element.AppendChildren(Div(), Div()) + assert.Equal(t, "
    ", Render(element)) +} + +func TestTagF(t *testing.T) { + t.Parallel() + element := TagF("div", "Hello %s", "World") + assert.Equal(t, "
    Hello World
    ", Render(element)) + + element2 := TagF("div", "Hello World", Class("my-class")) + assert.Equal(t, "
    Hello World
    ", Render(element2)) + + element3 := TagF("div", "Value", P(Text("Hello World"))) + assert.Equal(t, "
    Value

    Hello World

    ", Render(element3)) +} + +func TestTag(t *testing.T) { + t.Parallel() + element := Tag("div") + assert.Equal(t, "
    ", Render(element)) +} diff --git a/framework/h/xhr.go b/framework/h/xhr.go index 1c707e1..3ed884c 100644 --- a/framework/h/xhr.go +++ b/framework/h/xhr.go @@ -2,38 +2,35 @@ package h import ( "github.com/maddalax/htmgo/framework/hx" - "strings" ) +// Get adds two attributes to the element: hx-get and hx-trigger. func Get(path string, trigger ...string) *AttributeMapOrdered { return AttributeList(Attribute(hx.GetAttr, path), HxTriggerString(trigger...)) } +// GetPartial adds two attributes to the element: hx-get and hx-trigger, and uses the partial path for the hx-get attribute. func GetPartial(partial PartialFunc, trigger ...string) *AttributeMapOrdered { return Get(GetPartialPath(partial), trigger...) } +// GetPartialWithQs adds two attributes to the element: hx-get and hx-trigger, and uses the partial path for the hx-get attribute. It also sets the query string parameters. func GetPartialWithQs(partial PartialFunc, qs *Qs, trigger string) *AttributeMapOrdered { return Get(GetPartialPathWithQs(partial, qs), trigger) } +// GetWithQs adds two attributes to the element: hx-get and hx-trigger, and uses the path for the hx-get attribute. It also sets the query string parameters. func GetWithQs(path string, qs *Qs, trigger string) *AttributeMapOrdered { return Get(SetQueryParams(path, qs), trigger) } +// PostPartial adds two attributes to the element: hx-post and hx-trigger, and uses the partial path for the hx-post attribute. func PostPartial(partial PartialFunc, triggers ...string) *AttributeMapOrdered { - path := GetPartialPath(partial) - if !strings.HasPrefix(path, "/") { - path = "/" + path - } - return Post(path, triggers...) + return Post(GetPartialPath(partial), triggers...) } +// PostPartialWithQs adds two attributes to the element: hx-post and hx-trigger, and uses the partial path for the hx-post attribute. It also sets the query string parameters. func PostPartialWithQs(partial PartialFunc, qs *Qs, trigger ...string) *AttributeMapOrdered { - path := GetPartialPathWithQs(partial, qs) - if !strings.HasPrefix(path, "/") { - path = "/" + path - } return Post(GetPartialPathWithQs(partial, qs), trigger...) } @@ -41,18 +38,22 @@ func Post(url string, trigger ...string) *AttributeMapOrdered { return AttributeList(Attribute(hx.PostAttr, url), HxTriggerString(trigger...)) } +// PostWithQs adds two attributes to the element: hx-post and hx-trigger, and uses the path for the hx-post attribute. It also sets the query string parameters. func PostWithQs(url string, qs *Qs, trigger string) *AttributeMapOrdered { return Post(SetQueryParams(url, qs), trigger) } +// PostOnClick adds two attributes to the element: hx-post and hx-trigger, and uses the path for the hx-post attribute. It also sets the hx-trigger to hx-click. func PostOnClick(url string) *AttributeMapOrdered { return Post(url, hx.ClickEvent) } +// PostPartialOnClick adds two attributes to the element: hx-post and hx-trigger, and uses the partial path for the hx-post attribute. It also sets the hx-trigger to hx-click. func PostPartialOnClick(partial PartialFunc) *AttributeMapOrdered { return PostOnClick(GetPartialPath(partial)) } +// PostPartialOnClickQs adds two attributes to the element: hx-post and hx-trigger, and uses the partial path for the hx-post attribute. It also sets the hx-trigger to hx-click. It also sets the query string parameters. func PostPartialOnClickQs(partial PartialFunc, qs *Qs) *AttributeMapOrdered { return PostOnClick(GetPartialPathWithQs(partial, qs)) } diff --git a/framework/h/xhr_test.go b/framework/h/xhr_test.go new file mode 100644 index 0000000..2153a07 --- /dev/null +++ b/framework/h/xhr_test.go @@ -0,0 +1,92 @@ +package h + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGet(t *testing.T) { + attr := Div(Get("/path", "load")) + assert.Equal(t, `
    `, Render(attr)) +} + +func TestGetPartial(t *testing.T) { + partial := func(ctx *RequestContext) *Partial { + return &Partial{} + } + attr := Div(GetPartial(partial, "load")) + expected := Render(Div(Get(GetPartialPath(partial), "load"))) + assert.Equal(t, expected, Render(attr)) +} + +func TestGetPartialWithQs(t *testing.T) { + partial := func(ctx *RequestContext) *Partial { + return &Partial{} + } + qs := NewQs("param", "value") + attr := Div(GetPartialWithQs(partial, qs, "load")) + expectedPath := Render(Div(Get(GetPartialPathWithQs(partial, qs), "load"))) + assert.Equal(t, expectedPath, Render(attr)) +} + +func TestPost(t *testing.T) { + attr := Div(Post("/path", "submit")) + assert.Equal(t, `
    `, Render(attr)) +} + +func TestPostOnClick(t *testing.T) { + attr := Div(PostOnClick("/path")) + assert.Equal(t, `
    `, Render(attr)) +} + +func TestPostPartialOnClick(t *testing.T) { + partial := func(ctx *RequestContext) *Partial { + return &Partial{} + } + attr := Div(PostPartialOnClick(partial)) + expected := Render(Div(PostOnClick(GetPartialPath(partial)))) + assert.Equal(t, expected, Render(attr)) +} + +func TestPostPartialWithQs(t *testing.T) { + partial := func(ctx *RequestContext) *Partial { + return &Partial{} + } + qs := NewQs("key", "value") + attr := Div(PostPartialWithQs(partial, qs, "click")) + expected := Render(Div(Post(GetPartialPathWithQs(partial, qs), "click"))) + assert.Equal(t, expected, Render(attr)) +} + +func TestPostPartialOnClickQs(t *testing.T) { + partial := func(ctx *RequestContext) *Partial { + return &Partial{} + } + qs := NewQs("key", "value") + attr := Div(PostPartialOnClickQs(partial, qs)) + expected := Render(Div(PostOnClick(GetPartialPathWithQs(partial, qs)))) + assert.Equal(t, expected, Render(attr)) +} + +func TestGetWithQs(t *testing.T) { + qs := NewQs("param1", "value1", "param2", "value2") + attr := Div(GetWithQs("/path", qs, "load")) + expected := `
    ` + assert.Equal(t, expected, Render(attr)) +} + +func TestPostWithQs(t *testing.T) { + qs := NewQs("param1", "value1", "param2", "value2") + attr := Div(PostWithQs("/path", qs, "submit")) + expected := `
    ` + assert.Equal(t, expected, Render(attr)) +} + +func TestPostPartial(t *testing.T) { + partial := func(ctx *RequestContext) *Partial { + return &Partial{} + } + attr := Div(PostPartial(partial, "submit")) + expected := Render(Div(Post(GetPartialPath(partial), "submit"))) + assert.Equal(t, expected, Render(attr)) +} diff --git a/framework/internal/datastructure/map.go b/framework/internal/datastructure/map.go deleted file mode 100644 index e4741d2..0000000 --- a/framework/internal/datastructure/map.go +++ /dev/null @@ -1,84 +0,0 @@ -package datastructure - -type MapEntry[K comparable, V any] struct { - Key K - Value V -} - -// 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 -} - -func (om *OrderedMap[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() []MapEntry[K, V] { - entries := make([]MapEntry[K, V], len(om.keys)) - for i, key := range om.keys { - entries[i] = MapEntry[K, 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 - } - } - } -} diff --git a/framework/js/commands.go b/framework/js/commands.go index 4cb69d8..b1703e3 100644 --- a/framework/js/commands.go +++ b/framework/js/commands.go @@ -14,6 +14,11 @@ var SetDisabled = h.SetDisabled var RemoveClass = h.RemoveClass var Alert = h.Alert var SetClassOnChildren = h.SetClassOnChildren +var ToggleClassOnChildren = h.ToggleClassOnChildren +var ToggleClassOnParent = h.ToggleClassOnParent +var SetClassOnParent = h.SetClassOnParent +var RemoveClassOnParent = h.RemoveClassOnParent +var ToggleClassOnSibling = h.ToggleClassOnSibling var RemoveClassOnChildren = h.RemoveClassOnChildren var EvalJsOnChildren = h.EvalJsOnChildren var EvalJsOnSibling = h.EvalJsOnSibling @@ -23,6 +28,8 @@ var RemoveClassOnSibling = h.RemoveClassOnSibling var Remove = h.Remove var PreventDefault = h.PreventDefault var EvalJs = h.EvalJs +var EvalCommands = h.EvalCommands +var EvalCommandsOnSelector = h.EvalCommandsOnSelector var ConsoleLog = h.ConsoleLog var SetValue = h.SetValue var SubmitFormOnEnter = h.SubmitFormOnEnter @@ -36,3 +43,7 @@ var GetWithQs = h.GetWithQs var PostWithQs = h.PostWithQs var ToggleClass = h.ToggleClass var ToggleClassOnElement = h.ToggleClassOnElement +var ToggleText = h.ToggleText +var ToggleTextOnSibling = h.ToggleTextOnSibling +var ToggleTextOnChildren = h.ToggleTextOnChildren +var ToggleTextOnParent = h.ToggleTextOnParent diff --git a/framework/service/locator.go b/framework/service/locator.go index 855cae0..f38f70e 100644 --- a/framework/service/locator.go +++ b/framework/service/locator.go @@ -10,6 +10,7 @@ type Lifecycle = string var ( Singleton Lifecycle = "singleton" + Transient Lifecycle = "transient" ) type Provider struct { @@ -23,6 +24,13 @@ type Locator struct { mutex sync.RWMutex } +// NewLocator creates a new locator to register services +// Usage: +// +// locator := service.NewLocator() +// service.Set[db.Queries](locator, service.Singleton, db.Provide) +// +// service.Get[db.Queries](locator) func NewLocator() *Locator { return &Locator{ services: make(map[string]Provider), @@ -35,10 +43,17 @@ func (l *Locator) setCache(key string, value any) { l.cache[key] = value } +func (l *Locator) clearCache(key string) { + delete(l.cache, key) +} + func (l *Locator) getCache(key string) any { return l.cache[key] } +// Get returns a service from the locator +// If the service is not found, log.Fatalf is called +// If the service is a singleton, it will be cached after first invocation func Get[T any](locator *Locator) *T { locator.mutex.RLock() i := new(T) @@ -65,13 +80,17 @@ func Get[T any](locator *Locator) *T { return cb } +// Set registers a service with the locator +// If the service is a singleton, it will be cached after first invocation of Get func Set[T any](locator *Locator, lifecycle Lifecycle, value func() *T) { t := reflect.TypeOf(value) rt := t.Out(0) - locator.services[rt.String()] = Provider{ + key := rt.String() + locator.services[key] = Provider{ cb: func() any { return value() }, lifecycle: lifecycle, } + locator.clearCache(key) } diff --git a/htmgo-site/assets/public/apple-touch-icon.png b/htmgo-site/assets/public/apple-touch-icon.png index d10e9fe..9430f21 100644 Binary files a/htmgo-site/assets/public/apple-touch-icon.png and b/htmgo-site/assets/public/apple-touch-icon.png differ diff --git a/htmgo-site/assets/public/auth-example.jpg b/htmgo-site/assets/public/auth-example.jpg new file mode 100644 index 0000000..9a66373 Binary files /dev/null and b/htmgo-site/assets/public/auth-example.jpg differ diff --git a/htmgo-site/assets/public/favicon-96x96.png b/htmgo-site/assets/public/favicon-96x96.png new file mode 100644 index 0000000..df16ca9 Binary files /dev/null and b/htmgo-site/assets/public/favicon-96x96.png differ diff --git a/htmgo-site/assets/public/favicon.ico b/htmgo-site/assets/public/favicon.ico index 040cccf..1022b26 100644 Binary files a/htmgo-site/assets/public/favicon.ico and b/htmgo-site/assets/public/favicon.ico differ diff --git a/htmgo-site/assets/public/favicon.svg b/htmgo-site/assets/public/favicon.svg new file mode 100644 index 0000000..024a9b4 --- /dev/null +++ b/htmgo-site/assets/public/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/htmgo-site/assets/public/formatter.mp4 b/htmgo-site/assets/public/formatter.mp4 new file mode 100644 index 0000000..f44fb7d Binary files /dev/null and b/htmgo-site/assets/public/formatter.mp4 differ diff --git a/htmgo-site/assets/public/jetbrains-tailwind.json b/htmgo-site/assets/public/jetbrains-tailwind.json new file mode 100644 index 0000000..18f9434 --- /dev/null +++ b/htmgo-site/assets/public/jetbrains-tailwind.json @@ -0,0 +1,14 @@ +{ + "includeLanguages": { + "go": "html" + }, + "experimental": { + "configFile": null, + "classRegex": [ + ["Class\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassX\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassIf\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["Classes\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"] + ] + } +} diff --git a/htmgo-site/assets/public/site.webmanifest b/htmgo-site/assets/public/site.webmanifest new file mode 100644 index 0000000..7eaf1ee --- /dev/null +++ b/htmgo-site/assets/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "htmgo", + "short_name": "htmgo", + "icons": [ + { + "src": "/public/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/public/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/htmgo-site/assets/public/vscode-tailwind.json b/htmgo-site/assets/public/vscode-tailwind.json new file mode 100644 index 0000000..90c0a18 --- /dev/null +++ b/htmgo-site/assets/public/vscode-tailwind.json @@ -0,0 +1,11 @@ +{ + "tailwindCSS.includeLanguages": { + "go": "html" + }, + "tailwindCSS.experimental.classRegex": [ + ["Class\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassX\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassIf\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["Classes\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`"] + ] +} diff --git a/htmgo-site/assets/public/web-app-manifest-192x192.png b/htmgo-site/assets/public/web-app-manifest-192x192.png new file mode 100644 index 0000000..dd5087d Binary files /dev/null and b/htmgo-site/assets/public/web-app-manifest-192x192.png differ diff --git a/htmgo-site/assets/public/web-app-manifest-512x512.png b/htmgo-site/assets/public/web-app-manifest-512x512.png new file mode 100644 index 0000000..2b0a1c4 Binary files /dev/null and b/htmgo-site/assets/public/web-app-manifest-512x512.png differ diff --git a/htmgo-site/go.mod b/htmgo-site/go.mod index 45b0ca4..ff89be3 100644 --- a/htmgo-site/go.mod +++ b/htmgo-site/go.mod @@ -4,16 +4,17 @@ go 1.23.0 require ( github.com/alecthomas/chroma/v2 v2.14.0 + 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/tools/html-to-htmgo v0.0.0-20241011161932-8b9e536f1490 + github.com/maddalax/htmgo/framework v1.0.3-0.20241101111035-2c4ac8b2866d + github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241101111035-2c4ac8b2866d github.com/yuin/goldmark v1.7.4 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc ) require ( github.com/dlclark/regexp2 v1.11.0 // indirect - 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 + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect ) diff --git a/htmgo-site/go.sum b/htmgo-site/go.sum index cb6ec6e..6568432 100644 --- a/htmgo-site/go.sum +++ b/htmgo-site/go.sum @@ -19,10 +19,10 @@ 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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -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/tools/html-to-htmgo v0.0.0-20241011161932-8b9e536f1490 h1:D7jkugRnEtKACr4kQH6eSNxB8cKXgrhLm+5yeLsvscg= -github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241011161932-8b9e536f1490/go.mod h1:hpDNkFnNT0FIgmQsVjMeQOzLuPxaqmkbNuws3zh4gWs= +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/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241101111035-2c4ac8b2866d h1:uc4h6gRfVQ/OjYXWg46xwT1cRAQlDJjo9RjoP4f2CLs= +github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241101111035-2c4ac8b2866d/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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -38,6 +38,8 @@ 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/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/htmgo-site/htmgo.yml b/htmgo-site/htmgo.yml index d60d2ff..926ab7d 100644 --- a/htmgo-site/htmgo.yml +++ b/htmgo-site/htmgo.yml @@ -8,3 +8,7 @@ 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"] + +# files or directories to ignore when automatically registering routes for pages +# supports glob patterns through https://github.com/bmatcuk/doublestar +automatic_page_routing_ignore: ["base/root.go", "docs/base.go"] diff --git a/htmgo-site/internal/markdown/render.go b/htmgo-site/internal/markdown/render.go index 8677ac9..51b286b 100644 --- a/htmgo-site/internal/markdown/render.go +++ b/htmgo-site/internal/markdown/render.go @@ -2,6 +2,8 @@ package markdown import ( "bytes" + "github.com/alecthomas/chroma/v2" + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark/extension" @@ -9,10 +11,12 @@ import ( "github.com/yuin/goldmark/renderer/html" "io" "io/fs" + "sync" ) type Renderer struct { cache map[string]string + lock sync.Mutex } func NewRenderer() *Renderer { @@ -20,6 +24,8 @@ func NewRenderer() *Renderer { } func (r *Renderer) RenderFile(source string, system fs.FS) string { + r.lock.Lock() + defer r.lock.Unlock() if val, ok := r.cache[source]; ok { return val } @@ -49,9 +55,16 @@ func RenderMarkdown(reader io.Reader) bytes.Buffer { ), goldmark.WithRendererOptions( html.WithUnsafe(), + html.WithHardWraps(), ), goldmark.WithExtensions( highlighting.NewHighlighting( + highlighting.WithFormatOptions( + chromahtml.WithLineNumbers(true), + chromahtml.WithCustomCSS(map[chroma.TokenType]string{ + chroma.PreWrapper: "font-size: 14px; padding: 12px; overflow: auto; background-color: rgb(245, 245, 245) !important;", + }), + ), highlighting.WithStyle("github"), ), ), diff --git a/htmgo-site/internal/sitemap/generate.go b/htmgo-site/internal/sitemap/generate.go index 8a52fd6..802c4d8 100644 --- a/htmgo-site/internal/sitemap/generate.go +++ b/htmgo-site/internal/sitemap/generate.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/xml" "fmt" + "github.com/go-chi/chi/v5" + "strings" ) type URL struct { @@ -35,8 +37,8 @@ func serialize(sitemap *URLSet) ([]byte, error) { return buffer.Bytes(), nil } -func Generate() ([]byte, error) { - +func Generate(router *chi.Mux) ([]byte, error) { + routes := router.Routes() urls := []URL{ { Loc: "/", @@ -59,6 +61,29 @@ func Generate() ([]byte, error) { ChangeFreq: "weekly", }, } + + for _, route := range routes { + if strings.HasPrefix(route.Pattern, "/docs/") { + urls = append(urls, URL{ + Loc: route.Pattern, + Priority: 1.0, + ChangeFreq: "weekly", + }) + } + + if strings.HasPrefix(route.Pattern, "/examples/") { + urls = append(urls, URL{ + Loc: route.Pattern, + Priority: 0.7, + ChangeFreq: "weekly", + }) + } + } + + for i, url := range urls { + urls[i].Loc = fmt.Sprintf("%s%s", "https://htmgo.dev", url.Loc) + } + sitemap := NewSitemap(urls) return serialize(sitemap) } diff --git a/htmgo-site/internal/urlhelper/resolve.go b/htmgo-site/internal/urlhelper/resolve.go new file mode 100644 index 0000000..d3d6e5c --- /dev/null +++ b/htmgo-site/internal/urlhelper/resolve.go @@ -0,0 +1,28 @@ +package urlhelper + +import ( + "github.com/maddalax/htmgo/framework/h" + "net/url" +) + +func ToAbsoluteUrl(ctx *h.RequestContext, path string) string { + // Define the relative path you want to add + relativePath := path + + // Parse the current request URL + currentURL := ctx.Request.URL + + // Set scheme and host from the request to create an absolute URL + scheme := "http" + if ctx.Request.TLS != nil { + scheme = "https" + } + currentURL.Host = ctx.Request.Host + currentURL.Scheme = scheme + + // Combine the base URL with the relative path + absoluteURL := currentURL.ResolveReference(&url.URL{Path: relativePath}) + + // Output the full absolute URL + return absoluteURL.String() +} diff --git a/htmgo-site/main.go b/htmgo-site/main.go index 2ee2dc7..4bafd98 100644 --- a/htmgo-site/main.go +++ b/htmgo-site/main.go @@ -37,7 +37,7 @@ func main() { http.FileServerFS(sub) app.Router.Handle("/sitemap.xml", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s, err := sitemap.Generate() + s, err := sitemap.Generate(app.Router) if err != nil { http.Error(w, "failed to generate sitemap", http.StatusInternalServerError) return diff --git a/htmgo-site/md/docs/1_quick-start/1_introduction.md b/htmgo-site/md/docs/1_quick-start/1_introduction.md deleted file mode 100644 index 992cdd9..0000000 --- a/htmgo-site/md/docs/1_quick-start/1_introduction.md +++ /dev/null @@ -1,68 +0,0 @@ -## Introduction - -htmgo is a lightweight pure go way to build interactive websites / web applications using go & htmx. -We give you the utilities to build html using pure go code in a reusable way (go functions are components) while also providing htmx functions to add interactivity to your app. - -```go -func DocsPage(ctx *h.RequestContext) *h.Page { - assets := ctx.Get("embeddedMarkdown").(fs.FS) - pages := dirwalk.WalkPages("md/docs", assets) - - return h.NewPage(base.RootPage( - h.Div( - h.Class("flex flex-col md:flex-row gap-4 justify-center mb-12"), - partials.DocSidebar(pages), - h.Div( - h.Class("flex flex-col justify-center items-center mt-6"), - h.List(pages, func(page *dirwalk.Page, index int) *h.Element { - return h.Div( - h.Class("border-b border-b-slate-300 w-full pb-8 mb-8"), - MarkdownContent(ctx, - page.FilePath, - partials.CreateAnchor(page.Parts)), - ) - }), - ), - ), - )) -} -``` - -**[The site you are reading now](https://github.com/maddalax/htmgo/tree/master/htmgo-site) was written with htmgo!** - -
    - -**Quick overview** - -1. Server side rendered html, deploy as a single binary - -2. Built in live reloading - -3. Built in support for various libraries such as tailwindcss, htmx - -4. Go functions are components, no special syntax necessary to learn - -5. Many composable utility functions to streamline development and reduce boilerplate - - ```go - func ChangeTab(ctx *h.RequestContext) *h.Partial { - service := tasks.NewService(ctx.ServiceLocator()) - list, _ := service.List() - - tab := ctx.QueryParam("tab") - - return h.SwapManyPartialWithHeaders(ctx, - h.PushQsHeader(ctx, h.NewQs("tab", tab)), - List(list, tab), - Footer(list, tab), - ) - } - ``` - - Example: **h.SwapManyPartialWithHeaders** to swap out multiple elements on the page with your response, as well as set a new query string parameter. - - - -
    - -See [#core-concepts](#core-concepts-pages) for more information. diff --git a/htmgo-site/md/docs/1_quick-start/2_installation.md b/htmgo-site/md/docs/1_quick-start/2_installation.md deleted file mode 100644 index 30b1a3e..0000000 --- a/htmgo-site/md/docs/1_quick-start/2_installation.md +++ /dev/null @@ -1,63 +0,0 @@ -## Getting Started - - -##### **Prerequisites:** -1. Go: https://go.dev/doc/install -2. Familiarity with https://htmx.org and html/hypermedia - 1. If you have not read the htmx docs, please do so before continuing, a lot of concepts below will be much more clear after. - - -
    - -##### 1. **Install htmgo** - -```bash -GOPROXY=direct go install github.com/maddalax/htmgo/cli/htmgo@latest -``` - - - -**2. Create new project** -Once htmgo cli tool is installed, run - -```bash -htmgo template -``` - -this will ask you for a new app name, and it will clone our starter template to a new directory it creates with your app name. - -
    - -**3. Running the dev server** -htmgo has built in live reload on the dev server, to use this, run this command in the root of your project - -```bash -htmgo watch -``` - -If you prefer to restart the dev server yourself (no live reload), use - -```bash -htmgo run -``` - - - -##### **4. Core concepts** - -View the [core concepts](/docs#core-concepts-pages) of how to use htmgo, such as adding pages, using partials, routing, etc. - -
    - -**5. Building for production** -htmgo cli can be used to build the application for production as a single binary - -```bash -htmgo build -``` - -it will be output to **./dist** - - - -
    diff --git a/htmgo-site/md/docs/2_core-concepts/1_pages.md b/htmgo-site/md/docs/2_core-concepts/1_pages.md deleted file mode 100644 index 20c0338..0000000 --- a/htmgo-site/md/docs/2_core-concepts/1_pages.md +++ /dev/null @@ -1,94 +0,0 @@ -## Pages - -Pages are the entry point of an htmgo application. - -A simple page may look like: - -```go -// route will be automatically registered based on the file name -func HelloHtmgoPage(ctx *h.RequestContext) *h.Page { - return h.NewPage( - h.Html( - h.HxExtension(h.BaseExtensions()), - h.Head( - h.Link("/public/main.css", "stylesheet"), - h.Script("/public/htmgo.js"), - ), - h.Body( - h.Pf("Hello, htmgo!"), - ), - ), - ) -} -``` - -htmgo uses [std http](https://pkg.go.dev/net/http) with chi router as its web server, ***h.RequestContext** is a thin wrapper around ***http.Request**. A page -must return *h.Page, and accept *h.RequestContext as a parameter - -
    - -**Auto Registration** - -htmgo uses file based routing. This means that we will automatically generate and register your routes with chi based on the files you have in the 'pages' directory. - -For example, if you have a directory structure such as: - -```bash -pages - index.go - users.go - users.$id //id parameter can be accessed in your page with ctx.Param("id") -``` - -it will get registered into chi router as follows: - -```bash -/ -/users -/users/:id -``` - -You may put any functions you like in your pages file, auto registration will **ONLY** register functions that return ***h.Page** - -
    - -**Tips:** - -Generally it is it recommended to abstract common parts of your page into its own component and re-use it, such as script tags, including styling, etc. - -Example: - -```go -func RootPage(children ...h.Ren) *h.Element { - return h.Html( - h.HxExtension(h.BaseExtensions()), - h.Head( - h.Meta("viewport", "width=device-width, initial-scale=1"), - h.Link("/public/main.css", "stylesheet"), - h.Script("/public/htmgo.js"), - h.Style(` - html { - scroll-behavior: smooth; - } - `), - ), - h.Body( - h.Class("bg-stone-50 min-h-screen overflow-x-hidden"), - partials.NavBar(false), - h.Fragment(children...), - ), - ) -} -``` - -```go -func UserPage(ctx *h.RequestContext) *h.Page { - return h.NewPage( - base.RootPage( - h.Div( - h.Pf("User ID: %s", ctx.Param("id")), - ), - )) -} -``` - diff --git a/htmgo-site/md/docs/2_core-concepts/2_partials.md b/htmgo-site/md/docs/2_core-concepts/2_partials.md deleted file mode 100644 index 9757880..0000000 --- a/htmgo-site/md/docs/2_core-concepts/2_partials.md +++ /dev/null @@ -1,58 +0,0 @@ -## Partials - -Partials are where things get interesting. Partials allow you to start adding interactivity to your website by swapping in content, setting headers, redirecting, etc. - -Partials have a similar structure to pages. A simple partial may look like: - -```go -func CurrentTimePartial(ctx *h.RequestContext) *h.Partial { - now := time.Now() - return h.NewPartial( - h.Div( - h.Pf("The current time is %s", now.Format(time.RFC3339)), - ), - ) -} -``` - -This will get automatically registered in the same way that pages are registered, based on the file path. This allows you to reference partials directly via the function itself when rendering them, instead of worrying about the route. - -**Example:** -I want to build a page that renders the current time, updating every second. Here is how that may look: - -
    - -**pages/time.go** - -```go -package pages - -func CurrentTimePage(ctx *h.RequestContext) *h.Page { - return h.NewPage( - base.RootPage( - h.GetPartial( - partials.CurrentTimePartial, - "load, every 1s"), - )) -} -``` - -**partials/time.go** - -```go -package partials - -func CurrentTimePartial(ctx *h.RequestContext) *h.Partial { - now := time.Now() - return h.NewPartial( - h.Div( - h.Pf("The current time is %s", now.Format(time.RFC3339)), - ), - ) -} -``` - -When the page load, the partial will be loaded in via htmx, and then swapped in every 1 second. With this -little amount of code and zero written javascript, you have a page that shows the current time and updates -every second. - diff --git a/htmgo-site/md/docs/2_core-concepts/3_components.md b/htmgo-site/md/docs/2_core-concepts/3_components.md deleted file mode 100644 index 9cbf2d2..0000000 --- a/htmgo-site/md/docs/2_core-concepts/3_components.md +++ /dev/null @@ -1,29 +0,0 @@ -## Components - -Components are re-usable bits of logic to render HTML. Similar to how in React components are Javascript functions, in htmgo, components are pure go functions. - -A component can be pure, or it can have data fetching logic inside of it. Since htmgo uses htmx for interactivity, there is NO re-rendering of your UI automatically from the framework, which means you can safely put data fetching logic inside of components since you can be sure they will only be called by your own code. - -
    - -**Example:** - -```go -func Card(ctx *h.RequestContext) *h.Element { - service := tasks.NewService(ctx.ServiceLocator()) - list, _ := service.List() - - return h.Div( - h.Id("task-card"), - h.Class("bg-white w-full rounded shadow-md"), - CardBody(list, getActiveTab(ctx)), - ) -} -``` - -My card component here fetches all my tasks I have on my list, and renders each task. -If you are familiar with React, then you would likely place this fetch logic inside of a useEffect or (useQuery library) so it is not constantly refetched as the component re-renders. - -With **htmgo**, the only way to update content on the page is to use htmx to swap out the content from loading a partial. Therefore you control exactly when this Card component is called, not the framework behind the scenes. - -See [#interactivity-swapping](#interactivity-swapping) for more information diff --git a/htmgo-site/md/docs/2_core-concepts/4_tags.md b/htmgo-site/md/docs/2_core-concepts/4_tags.md deleted file mode 100644 index aaeb8ba..0000000 --- a/htmgo-site/md/docs/2_core-concepts/4_tags.md +++ /dev/null @@ -1,19 +0,0 @@ -## HTML Tags - -htmgo provides many methods to render html tags: - -```go -h.Html(children ...Ren) *Element -h.Head(children ...Ren) *Element -h.Div(children ...Ren) *Element -h.Button(children ...Ren) *Element -h.P(children ...Ren) *Element -h.H1(children ...Ren) *Element -h.H2(children ...Ren) *Element -h.Tag(tag string, children ...Ren) *Element -... etc -``` - -All methods can be found in the `h` package in htmgo/framework - -See [#conditionals](#control-if-else) for more information about conditionally rendering tags or attributes. diff --git a/htmgo-site/md/docs/2_core-concepts/5_attributes.md b/htmgo-site/md/docs/2_core-concepts/5_attributes.md deleted file mode 100644 index a0b4c73..0000000 --- a/htmgo-site/md/docs/2_core-concepts/5_attributes.md +++ /dev/null @@ -1,22 +0,0 @@ -## Attributes - -Attributes are one of the main ways we can add interactivity to the pages with [htmx](http://htmx.org). If you have not read over the htmx documentation, please do so before continuing. - -htmgo provides many methods to add attributes - -```go -h.Class(string) -h.ClassX(string, h.ClassMap) -h.Href(string) -h.Attribute(key, value) -h.AttributeIf(condition, key, value) -h.AttributePairs(values...string) // set multiple attributes, must be an even number of parameters -h.Attributes(h.AttributeMap) // set multiple attributes as key/value pairs -h.Id(string) -h.Trigger(hx.Trigger) //htmx trigger using additional functions to construct the trigger -h.TriggerString(string) // htmx trigger in pure htmx string form - -``` - - - diff --git a/htmgo-site/md/docs/2_core-concepts/6_raw_html.md b/htmgo-site/md/docs/2_core-concepts/6_raw_html.md deleted file mode 100644 index 055639c..0000000 --- a/htmgo-site/md/docs/2_core-concepts/6_raw_html.md +++ /dev/null @@ -1,22 +0,0 @@ -## Rendering Raw Html - -In some cases, you may want to render raw HTML instead of using htmgo's functions. -This can be done by using the following methods: -```go -h.UnsafeRaw(string) -h.UnsafeRawF(string, ...interface{}) -h.UnsafeRawScript(string) -``` - -Usage: -```go - -h.UnsafeRaw("
    Raw HTML
    ") -h.UnsafeRawF("
    %s
    ", "Raw HTML") -h.UnsafeRawScript("alert('Hello World')") -``` - -Important: Be careful when using these methods, these methods do not escape the HTML content -and should **never** be used with user input unless you have sanitized the input. - -Sanitizing input can be done using the `html.EscapeString` function or by using https://github.com/microcosm-cc/bluemonday. diff --git a/htmgo-site/md/docs/3_control/1_If Else.md b/htmgo-site/md/docs/3_control/1_If Else.md deleted file mode 100644 index 3477237..0000000 --- a/htmgo-site/md/docs/3_control/1_If Else.md +++ /dev/null @@ -1,51 +0,0 @@ -## Conditional Statements - -If / else statements are useful when you want to conditionally render attributes or elements / components. - -htmgo provides a couple of utilities to do so: - -```go -h.If(condition, node) -h.Ternary(condition, node, node2) -h.ElementIf(condition, element) // this is neccessary if a method requires you to pass in *h.element -h.IfElse(condition, node, node2) //essentially an alias to h.Ternary -h.IfElseLazy(condition, func()node, func()node2) // useful for if something should only be called based on the condition -h.AttributeIf(condition, key string, value string) // adds an attribute if condition is true -h.ClassIf(condition, class string) // adds a class if condition is true -h.ClassX(classes, m.ClassMap{}) // allows you to include classes, but also render specific classes conditionally - -``` - -**Examples:** - -- Render `border-green-500` or `border-slate-400` conditionally - -```go -h.ClassX("w-10 h-10 border rounded-full", map[string]bool { - "border-green-500": task.CompletedAt != nil, - "border-slate-400": task.CompletedAt == nil, -}) -``` - - - -- Render an icon if the task is complete - -```go -h.If(task.CompletedAt != nil, CompleteIcon()) -``` - -- Render different elements based on a condition - -```go -h.IfElse(editing, EditTaskForm(), ViewTask()) -``` - -Note: This will execute both **EditTaskForm** and **ViewTask**, no matter if the condition is true or false, since a function is being called here. - -If you do not want to call the function at all unless the condition is true, use **h.IfElseLazy** - -```go -h.IfElseLazy(editing, EditTaskForm, ViewTask) -``` - diff --git a/htmgo-site/md/docs/3_control/2_loops.md b/htmgo-site/md/docs/3_control/2_loops.md deleted file mode 100644 index c42e87c..0000000 --- a/htmgo-site/md/docs/3_control/2_loops.md +++ /dev/null @@ -1,38 +0,0 @@ -## Loops / Dealing With Lists - -Very commonly you will need to render a list or slice of items onto the page. Frameworks generally solve this in different ways, such as React uses regular JS .map function to solve it. - -We offer the same conveniences in htmgo. - -```go -h.List(items, func(item, index)) *h.Element -h.IterMap(map, mapper func(key, value) *Element) *Element -``` - -**Example:** - -- Render a list of tasks - -```go -h.List(list, func(item *ent.Task, index int) *h.Element { - if tab == TabComplete && item.CompletedAt == nil { - return h.Empty() - } - return Task(item, false) -}) -``` - -- Render a map - -```go - values := map[string]string{ - "key": "value", - } - - IterMap(values, func(key string, value string) *Element { - return Div( - Text(key), - Text(value), - ) - }) -``` diff --git a/htmgo-site/md/docs/4_interactivity/1_swapping.md b/htmgo-site/md/docs/4_interactivity/1_swapping.md deleted file mode 100644 index d8ba5eb..0000000 --- a/htmgo-site/md/docs/4_interactivity/1_swapping.md +++ /dev/null @@ -1,85 +0,0 @@ -## Interactivity / Swapping - -1. Adding interactivity to your website is done through [htmx](http://htmx.org) by utilizing various attributes/headers. This should cover most use cases. - htmgo offers utility methods to make this process a bit easier - -Here are a few methods we offer: - -Partial Response methods - -```go -SwapManyPartialWithHeaders(ctx *RequestContext, headers *Headers, swaps ...*Element) *Partial -SwapPartial(ctx *RequestContext, swap *Element) *Partial -SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial -SwapManyXPartial(ctx *RequestContext, swaps ...SwapArg) *Partial -GetPartialPath(partial PartialFunc) string -GetPartialPathWithQs(partial PartialFunc, qs *Qs) string -``` - -Swapping can also be done by adding a child to an element - -```go -OobSwapWithSelector(ctx *RequestContext, selector string, content *Element, option ...SwapOption) *Element -OobSwap(ctx *RequestContext, content *Element, option ...SwapOption) *Element -SwapMany(ctx *RequestContext, elements ...*Element) -``` - - - -Usage: - -1. I have a Card component that renders a list of tasks. I want to add a new button that completes all the tasks and updates the Card component with the completed tasks. - - -**/components/task.go** - -```go -func Card(ctx *h.RequestContext) *h.Element { - service := tasks.NewService(ctx.ServiceLocator()) - list, _ := service.List() - - return h.Div( - h.Id("task-card"), - h.Class("bg-white w-full rounded shadow-md"), - CardBody(list, getActiveTab(ctx)), - CompleteAllButton(list) - ) -} -``` - -```go -func CompleteAllButton(list []*ent.Task) *h.Element { - notCompletedCount := len(h.Filter(list, func(item *ent.Task) bool { - return item.CompletedAt == nil - })) - - return h.Button( - h.TextF("Complete %s tasks", notCompletedCount), - h.PostPartialWithQs(CompleteAll, - h.NewQs("complete", - h.Ternary(notCompletedCount > 0, "true", "false"), - )), - ) -} -``` - -**/partials/task.go** - -```go -func CompleteAll(ctx *h.RequestContext) *h.Partial { - service := tasks.NewService(ctx.ServiceLocator()) - service.SetAllCompleted(ctx.QueryParam("complete") == "true") - return h.SwapPartial(ctx, - Card(ctx), - ) -} -``` - -When the **CompleteAll** button is clicked, a **POST** will be sent to the **CompleteAll** partial, which will complete all the tasks and then swap out the Card content with the updated list of tasks. Pretty cool right? - -**SwapManyPartial** can be used to swap out multiple items on the page instead of a single one. - -Note: These partial swap methods use https://htmx.org/attributes/hx-swap-oob/ behind the scenes, so it must match -the swap target by id. - -**If** you are only wanting to swap the element that made the xhr request for the partial in the first place, just use `h.NewPartial` instead, it will use the default htmx swapping, and not hx-swap-oob. diff --git a/htmgo-site/md/docs/4_interactivity/2_events.md b/htmgo-site/md/docs/4_interactivity/2_events.md deleted file mode 100644 index ed750d8..0000000 --- a/htmgo-site/md/docs/4_interactivity/2_events.md +++ /dev/null @@ -1,45 +0,0 @@ -## Events Handlers / Commands - -Sometimes you need to update elements client side without having to do a network call. For this you generally have to target an element with javascript and set an attribute, change the innerHTML, etc. - -To make this work while still keeping a pure go feel, we offer a few utility methods to execute various javascript on an element. - -**Example:** When the form is submitted, set the button text to submitting and disable it, and vice versa after submit is done. - -```go -func MyForm() *h.Element { - return h.Form( - h.Button( - h.Text("Submit"), - h.HxBeforeRequest( - js.SetDisabled(true), - js.SetText("Submitting..."), - ), - h.HxAfterRequest( - js.SetDisabled(false), - js.SetText("Submit"), - ), - ), - ) -} -``` - -The structure of this comes down to: - -1. Add an event handler to the element -2. Add commands (found in the **js** package) as children to that event handler - -
    - -**Event Handlers:** - -```go -HxBeforeRequest(cmd ...Command) *LifeCycle -HxAfterRequest(cmd ...Command) *LifeCycle -HxOnMutationError(cmd ...Command) *LifeCycle -OnEvent(event hx.Event, cmd ...Command) *LifeCycle -OnClick(cmd ...Command) *LifeCycle -HxOnAfterSwap(cmd ...Command) *LifeCycle -HxOnLoad(cmd ...Command) *LifeCycle -``` - diff --git a/htmgo-site/md/docs/4_interactivity/3_evaluating_javascript.md b/htmgo-site/md/docs/4_interactivity/3_evaluating_javascript.md deleted file mode 100644 index 37affdb..0000000 --- a/htmgo-site/md/docs/4_interactivity/3_evaluating_javascript.md +++ /dev/null @@ -1,85 +0,0 @@ -## Evaluating Javascript In Event Handlers - -Event handlers are useful by attaching **commands** to elements to execute javascript on the client side. - -See [#interactivity-events](#interactivity-events) for more information on event handlers. - -
    - -**Note:** Each command you attach to the event handler will be passed 'self' and 'event' (if applicable) as arguments. -'self' is the current element, and 'event' is the event object. - -If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/). - -Commands: - -```go -js.AddAttribute(string, value) -js.RemoveAttribute(string) -js.AddClass(string, value) -js.SetText(string) -js.Increment(count) -js.SetInnerHtml(Ren) -js.SetOuterHtml(Ren) -js.SetDisabled(bool) -js.RemoveClass(string) -js.Alert(string) -js.EvalJs(string) // eval arbitrary js, use 'self' to get the current element as a reference -js.InjectScript(string) -js.InjectScriptIfNotExist(string) -js.GetPartial(PartialFunc) -js.GetPartialWithQs(PartialFunc, Qs) -js.PostPartial(PartialFunc) -js.PostPartialWithQs(PartialFunc, Qs) -js.GetWithQs(string, Qs) -js.PostWithQs(string, Qs) -js.ToggleClass(string) -js.ToggleClassOnElement(string, string) - -// The following methods are used to evaluate JS on nearby elements. -// Use 'element' to get the element as a reference for the EvalJs methods. -js.EvalJsOnParent(string) -js.EvalJsOnSibling(string, string) -js.EvalJsOnChildren(string, string) -js.SetClassOnParent(string) -js.RemoveClassOnParent(string) -js.SetClassOnChildren(string, string) -js.RemoveClassOnChildren(string, string) -js.SetClassOnSibling(string, string) -js.RemoveClassOnSibling(string, string) - -``` -For more usages: see https://github.com/maddalax/htmgo/blob/master/htmgo-site/pages/form.go - - -**Example:** Evaluating arbitrary JS - -```go -func MyButton() *h.Element { - return h.Button( - h.Text("Submit"), - h.OnClick( - // make sure you use 'self' instead of 'this' - // for referencing the current element - h.EvalJs(` - if(Math.random() > 0.5) { - self.innerHTML = "Success!"; - } - `, - ), - ), - ) -} -``` - -tip: If you are using Jetbrains IDE's, you can write `// language=js` as a comment above the function call (h.EvalJS) and it will automatically give you syntax highlighting on the raw JS. - -```go -// language=js -h.EvalJs(` - if(Math.random() > 0.5) { - self.innerHTML = "Success!"; - } - `, -), -``` diff --git a/htmgo-site/md/docs/5_performance/1_caching_globally.md b/htmgo-site/md/docs/5_performance/1_caching_globally.md deleted file mode 100644 index d8c7e9c..0000000 --- a/htmgo-site/md/docs/5_performance/1_caching_globally.md +++ /dev/null @@ -1,57 +0,0 @@ -## Performance -### Caching Components Globally - -You may want to cache components to improve performance. This is especially useful for components that are expensive to render -or make external requests for data. - -To cache a component in htmgo, we offer: - -```go -// No arguments passed to the component -h.Cached(duration time.Duration, cb GetElementFunc) -// One argument passed to the component -h.CachedT(duration time.Duration, cb GetElementFunc) -// Two arguments passed to the component -h.CachedT2(duration time.Duration, cb GetElementFunc) -// Three arguments passed to the component -h.CachedT3(duration time.Duration, cb GetElementFunc) -// Four arguments passed to the component -h.CachedT4(duration time.Duration, cb GetElementFunc) -``` -For caching components per user, see [Caching Components Per User](#performance-caching-per-user). - -
    - -The `duration` parameter is the time the component should be cached for. The `cb` parameter is a function that returns the component. - -When a request is made for a cached component, the component is rendered and stored in memory. Subsequent requests for the same component within the cache duration will return the cached component instead of rendering it again. - -**Usage:** - -```go -func ExpensiveComponent(ctx *h.RequestContext) *h.Element { - // Some expensive call - data := http.Get("https://api.example.com/data") - return h.Div( - h.Text(data), - ) -} - -var CachedComponent = h.CachedT(5*time.Minute, func(ctx *h.RequestContext) *h.Element { - return ExpensiveComponent(ctx) -}) - -func IndexPage(ctx *h.RequestContext) *h.Page { - return h.NewPage( - CachedComponent(ctx), - ) -} -``` - -**Note:** We are using CachedT because the component takes one argument, the RequestContext. -If the component takes more arguments, use CachedT2, CachedT3, etc. - -**Important Note When Using CachedT and NOT CachedPerKeyT:** -1. When using h.CachedT(T2, T3, etc) and not **CachedPerKey**, The cached value is stored globally in memory, so it is shared across all requests. Do not store request-specific data in a cached component. Only cache components that you are OK with all users seeing the same data. -2. The arguments passed into cached component **DO NOT** affect the cache key. You will get the same cached component regardless of the arguments passed in. This is different from what you may be used to from something like React useMemo. -3. Ensure the declaration of the cached component is **outside the function** that uses it. This is to prevent the component from being redeclared on each request. diff --git a/htmgo-site/md/docs/5_performance/1_caching_per_user.md b/htmgo-site/md/docs/5_performance/1_caching_per_user.md deleted file mode 100644 index 6ba40b0..0000000 --- a/htmgo-site/md/docs/5_performance/1_caching_per_user.md +++ /dev/null @@ -1,80 +0,0 @@ -### Caching Components Per User - -If you need to cache a component per user, you can use the `CachedPerKey` functions. -These functions allow you to cache a component by a specific key. This key can be any string that uniquely identifies the user. - -Note: I'm using the term 'user' to simply mean a unique identifier. This could be a user ID, session ID, or any other unique identifier. - -To cache a component by unique identifier / key in htmgo, we offer: - -```go -// No arguments passed to the component, the component can be cached by a specific key -h.CachedPerKey(duration time.Duration, cb GetElementFuncWithKey) -// One argument passed to the component, the component can be cached by a specific key -h.CachedPerKeyT1(duration time.Duration, cb GetElementFuncWithKey) -// Two argument passed to the component, the component can be cached by a specific key -h.CachedPerKeyT2(duration time.Duration, cb GetElementFuncWithKey) -// Three arguments passed to the component, the component can be cached by a specific key -h.CachedPerKeyT3(duration time.Duration, cb GetElementFuncWithKey) -// Four arguments passed to the component, the component can be cached by a specific key -h.CachedPerKeyT4(duration time.Duration, cb GetElementFuncWithKey) - -``` - -The `duration` parameter is the time the component should be cached for. The `cb` parameter is a function that returns the component and the key. - -When a request is made for a cached component, the component is rendered and stored in memory. Subsequent requests for the same component with the same key within the cache duration will return the cached component instead of rendering it again. - -**Usage:** - -```go -var CachedUserDocuments = h.CachedPerKeyT(time.Minute*15, func(ctx *h.RequestContext) (string, h.GetElementFunc) { - userId := getUserIdFromSession(ctx) - return userId, func() *h.Element { - return UserDocuments(ctx) - } -}) - -func UserDocuments(ctx *h.RequestContext) *h.Element { - docService := NewDocumentService(ctx) - // Expensive call - docs := docService.getDocuments() - return h.Div( - h.Class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"), - h.List(docs, func(doc Document, index int) *h.Element { - return h.Div( - h.Class("p-4 bg-white border border-gray-200 rounded-md"), - h.H3(doc.Title), - h.P(doc.Description), - ) - }), - ) -} - -func MyPage(ctx *h.RequestContext) *h.Page { - - // Note this is not a real way to create a context, just an example - user1 := &h.RequestContext{ - Session: "user_1_session", - } - - user2 := &h.RequestContext{ - Session: "user_2_session", - } - - // Different users will get different cached components - return h.NewPage( - CachedUserDocuments(user1), - CachedUserDocuments(user2), - ) -} - -``` - -**Note:** We are using CachedPerKeyT because the component takes one argument, the RequestContext. -If the component takes more arguments, use CachedPerKeyT2, CachedPerKeyT3, etc. - -**Important** -1. The cached value is stored globally in memory by key, it is shared across all requests. Ensure if you are storing request-specific data in a cached component, you are using a unique key for each user. -2. The arguments passed into cached component **DO NOT** affect the cache key. The only thing that affects the cache key is the key returned by the `GetElementFuncWithKey` function. -3. Ensure the declaration of the cached component is **outside the function** that uses it. This is to prevent the component from being redeclared on each request. diff --git a/htmgo-site/md/docs/6_pushing_data/1_server_sent_events.md b/htmgo-site/md/docs/6_pushing_data/1_server_sent_events.md deleted file mode 100644 index 2a185ab..0000000 --- a/htmgo-site/md/docs/6_pushing_data/1_server_sent_events.md +++ /dev/null @@ -1,65 +0,0 @@ -## Server Sent Events (SSE) - -htmgo supports server-sent events (SSE) out of the box. -This allows you to push data from the server to the client in real-time. - -Example of this can be found in the [chat-app](https://github.com/maddalax/htmgo/tree/master/examples/chat) example. -Demo: https://chat-example.htmgo.dev - -## How it works ## -1. The client sends a request to the server to establish a connection. -2. The server holds the connection open and sends data (in our case, most likely elements) to the client whenever there is new data to send. -3. The htmgo SSE extension uses https://htmx.org/attributes/hx-swap-oob/ to swap out the elements that the server sends. - - -**Note**: SSE is **unidirectional** (the server can only send data to the client). -For the client to send data to the server, normal xhr behavior should be used (form submission, triggers, etc). - -## Usage -1. Add the SSE connection attribute and the path to the handler that will handle the connection. - -```go -h.Attribute("sse-connect", fmt.Sprintf("/chat/%s", roomId)) -``` - -The following **Event Handlers** can be used to react to SSE connections. -```go -h.HxOnSseOpen -h.HxBeforeSseMessage -h.HxAfterSseMessage -h.HxOnSseError -h.HxOnSseClose -h.HxOnSseConnecting -``` - -**Example:** Adding an event listener handle SSE errors. - -```go -h.HxOnSseError( - js.EvalJs(fmt.Sprintf(` - const reason = e.detail.event.data - if(['invalid room', 'no session', 'invalid user'].includes(reason)) { - window.location.href = '/?roomId=%s'; - } else if(e.detail.event.code === 1011) { - window.location.reload() - } else if (e.detail.event.code === 1008 || e.detail.event.code === 1006) { - window.location.href = '/?roomId=%s'; - } else { - console.error('Connection closed:', e.detail.event) - } - `, roomId, roomId)), -), -``` - -**Example:** Clearing the input field after sending a message. -```go -func MessageInput() *h.Element { - return h.Input("text", - h.Id("message-input"), - h.Required(), - h.HxAfterSseMessage( - js.SetValue(""), - ), - ) -} -``` diff --git a/htmgo-site/md/docs/7_htmx_extensions/1_overview.md b/htmgo-site/md/docs/7_htmx_extensions/1_overview.md deleted file mode 100644 index b47de67..0000000 --- a/htmgo-site/md/docs/7_htmx_extensions/1_overview.md +++ /dev/null @@ -1,34 +0,0 @@ -## HTMX Extensions - -htmgo provides a few extra htmx extensions to make common tasks easier. -Some of these extensions are optional, and some of these are required for htmgo to work correctly. - -The following extensions are provided by htmgo: -- [Trigger Children](#htmx-extensions-trigger-children) -- [Mutation Error](#htmx-extensions-mutation-error) -- [SSE](#pushing-data-server-sent-events) -- [Path Deps](https://github.com/bigskysoftware/htmx-extensions/blob/main/src/path-deps/README.md) - -Default extensions should be included in your project by adding the following attribute to your html tag. -```go -h.Html( - h.HxExtension(h.BaseExtensions()) -) -``` - -If you need to combine multiple extensions, you can use: - -```go -h.HxExtensions(h.BaseExtensions(), "my-extension"), -``` -or -```go -h.JoinExtensions( - h.HxExtension("sse"), - h.HxExtension("my-extension"), -), -``` - - -**Important**: h.BaseExtensions will add the the 'htmgo' extension, which is a required extension for inline scripts to work properly, please always include it in your project. - diff --git a/htmgo-site/md/docs/7_htmx_extensions/2_trigger_children.md b/htmgo-site/md/docs/7_htmx_extensions/2_trigger_children.md deleted file mode 100644 index 0caf026..0000000 --- a/htmgo-site/md/docs/7_htmx_extensions/2_trigger_children.md +++ /dev/null @@ -1,13 +0,0 @@ -## HTMX Extensions - Trigger Children - -The `trigger-children` extension allows you to trigger an event on all children and siblings of an element. - -This is useful for things such as: -1. Letting a child element (such as a button) inside a form know the form was submitted - -
    - -**Example:** https://github.com/maddalax/htmgo/blob/master/htmgo-site/pages/form.go#L17 - -In this example: The trigger-children extension will trigger **hx-before-request** and **hx-after-request** -on all children of the form when the form is submitted, and the button reacts to that by showing a loading state. diff --git a/htmgo-site/md/docs/7_htmx_extensions/3_mutation_error.md b/htmgo-site/md/docs/7_htmx_extensions/3_mutation_error.md deleted file mode 100644 index 467206a..0000000 --- a/htmgo-site/md/docs/7_htmx_extensions/3_mutation_error.md +++ /dev/null @@ -1,24 +0,0 @@ -## HTMX Extensions - Mutation Error - -The `mutation-error` extension allows you to trigger an event when a request returns a >= 400 status code. - -This is useful for things such as: -1. Letting a child element (such as a button) inside a form know there was an error. - -
    - -**Example:** -```go -h.Form( - h.HxTriggerChildren(), - h.HxMutationError( - js.Alert("An error occurred"), - ), - h.Button( - h.Type("submit"), - h.Text("Submit"), - ), -) -``` - -It can also be used on children elements that do not make an xhr request, if you combine it with the `hx-trigger-children` extension. diff --git a/htmgo-site/md/docs/8_miscellaneous/1_tailwind_intellisense.md b/htmgo-site/md/docs/8_miscellaneous/1_tailwind_intellisense.md deleted file mode 100644 index 8c958dc..0000000 --- a/htmgo-site/md/docs/8_miscellaneous/1_tailwind_intellisense.md +++ /dev/null @@ -1,53 +0,0 @@ -## Tailwind intellisense - -Tailwind's language server allows you to specify custom configuration on what it should match to start giving you tailwind intellisense. - - -![](/public/tailwind-intellisense.png) - -To make this work, you will need to update the tailwind lsp config with the config below: - -Main thing to note here is -1. "go" is added to the includeLanguages list -2. classRegex is updated to match the tailwind classes in the go code. - -### Jetbrains IDE's (GoLand) -```json -{ - "includeLanguages": { - "go": "html" - }, - "experimental": { - "configFile": null, - "classRegex": [ - ["Class\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], - ["ClassX\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], - ["ClassIf\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], - ["Classes\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"] - ] - } -} -``` -To find this configuration in GoLand you can go to `Settings -> Languages & Frameworks -> Style Sheets -> Tailwind CSS` and update the configuration there. -These changes are additive, add these options to your existing tailwind lsp config, instead of replacing the entire file. - -See more: https://github.com/tailwindlabs/tailwindcss/issues/7553#issuecomment-735915659 - -
    - -### Visual Studio Code -For VSCode, you should be able to update your settings.json with the following values: - -```json -{ - "tailwindCSS.includeLanguages": { - "go": "html" - }, - "tailwindCSS.experimental.classRegex": [ - ["Class\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], - ["ClassX\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], - ["ClassIf\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], - ["Classes\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`"] - ] -} -``` diff --git a/htmgo-site/md/docs/8_miscellaneous/2_converting_raw_html_to_go.md b/htmgo-site/md/docs/8_miscellaneous/2_converting_raw_html_to_go.md deleted file mode 100644 index 54fb86c..0000000 --- a/htmgo-site/md/docs/8_miscellaneous/2_converting_raw_html_to_go.md +++ /dev/null @@ -1,4 +0,0 @@ -## Converting Raw HTML to Go - -In some cases, you may want to convert raw HTML to Go code. -A tool to do this automatically is available here: https://htmgo.dev/html-to-go diff --git a/htmgo-site/md/docs/9_troubleshooting/1_common_issues.md b/htmgo-site/md/docs/9_troubleshooting/1_common_issues.md deleted file mode 100644 index e9d9792..0000000 --- a/htmgo-site/md/docs/9_troubleshooting/1_common_issues.md +++ /dev/null @@ -1,4 +0,0 @@ -## Troubleshooting: - -**command not found: htmgo** -ensure you installed htmgo above and ensure GOPATH is set in your shell diff --git a/htmgo-site/md/index.md b/htmgo-site/md/index.md index 3e9ca0c..5325c88 100644 --- a/htmgo-site/md/index.md +++ b/htmgo-site/md/index.md @@ -22,5 +22,23 @@ 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/master/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks +5. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/master/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks + +------ + +**what can be built with htmgo?** + +Most web applications can be built with htmgo, including but not limited to: + +- traditional business CRUD applications +- blogs +- documentation sites +- consumer facing websites +- internal tools +- and more + +
    + +For a more detailed overview of when you should use hypermedia to build web applications, see [when-to-use-hypermedia](https://htmx.org/essays/when-to-use-hypermedia/) from htmx.org. + +Interested in some examples? Check out [examples](/examples). diff --git a/htmgo-site/pages/base/root.go b/htmgo-site/pages/base/root.go index 4b0e185..03e546f 100644 --- a/htmgo-site/pages/base/root.go +++ b/htmgo-site/pages/base/root.go @@ -3,43 +3,70 @@ package base import ( "github.com/google/uuid" "github.com/maddalax/htmgo/framework/h" + "htmgo-site/__htmgo/assets" "htmgo-site/partials" ) var Version = uuid.NewString()[0:6] -func RootPage(ctx *h.RequestContext, children ...h.Ren) *h.Element { +type RootPageProps struct { + Title string + Description string + Canonical string + Children h.Ren +} + +func ConfigurableRootPage(ctx *h.RequestContext, props RootPageProps) *h.Page { title := "htmgo" description := "build simple and scalable systems with go + htmx" + canonical := ctx.Request.URL.String() - return h.Html( - h.HxExtension(h.BaseExtensions()), - h.Head( - h.Meta("viewport", "width=device-width, initial-scale=1"), - h.Meta("title", title), - h.Link("/public/favicon.ico", "icon"), - h.Link("/public/apple-touch-icon.png", "apple-touch-icon"), - h.Meta("charset", "utf-8"), - h.Meta("author", "htmgo"), - h.Meta("description", description), - h.Meta("og:title", title), - h.Meta("og:url", "https://htmgo.dev"), - h.Link("canonical", "https://htmgo.dev"), - h.Link("https://cdn.jsdelivr.net/npm/@docsearch/css@3", "stylesheet"), - h.Meta("og:description", description), - h.LinkWithVersion("/public/main.css", "stylesheet", Version), - h.ScriptWithVersion("/public/htmgo.js", Version), - h.Style(` + if props.Canonical != "" { + canonical = props.Canonical + } + + if props.Title != "" { + title = props.Title + } + + if props.Description != "" { + description = props.Description + } + + return h.NewPage( + h.Html( + h.HxExtension( + h.BaseExtensions(), + ), + h.Head( + h.Meta("viewport", "width=device-width, initial-scale=1"), + h.Meta("title", title), + h.Title( + h.Text(title), + ), + h.Link(assets.FaviconIco, "icon"), + h.Link(assets.AppleTouchIconPng, "apple-touch-icon"), + h.Meta("charset", "utf-8"), + h.Meta("author", "htmgo"), + h.Meta("description", description), + h.Meta("og:title", title), + h.Meta("og:url", ctx.Request.URL.String()), + h.Link("canonical", canonical), + h.Link("https://cdn.jsdelivr.net/npm/@docsearch/css@3", "stylesheet"), + h.Meta("og:description", description), + h.LinkWithVersion(assets.MainCss, "stylesheet", Version), + h.ScriptWithVersion(assets.HtmgoJs, Version), + h.Style(` html { scroll-behavior: smooth; } `), - ), - h.Body( - h.Class("bg-stone-50 h-screen"), - h.Fragment(children...), - h.Script("https://cdn.jsdelivr.net/npm/@docsearch/js@3"), - h.UnsafeRawScript(` + ), + h.Body( + h.Class("bg-white h-screen"), + props.Children, + h.Script("https://cdn.jsdelivr.net/npm/@docsearch/js@3"), + h.UnsafeRawScript(` docsearch({ insights: true, appId: "9IO2WZA8L1", @@ -49,12 +76,36 @@ func RootPage(ctx *h.RequestContext, children ...h.Ren) *h.Element { debug: false }); `), + ), ), ) } -func PageWithNav(ctx *h.RequestContext, children ...h.Ren) *h.Element { - return RootPage(ctx, +func RootPageWithTitle(ctx *h.RequestContext, title string, children ...h.Ren) *h.Page { + return ConfigurableRootPage( + ctx, + RootPageProps{ + Title: title, + Description: "build simple and scalable systems with go + htmx", + Children: h.Fragment(children...), + }, + ) +} + +func RootPage(ctx *h.RequestContext, children ...h.Ren) *h.Page { + return ConfigurableRootPage( + ctx, + RootPageProps{ + Title: "htmgo", + Description: "build simple and scalable systems with go + htmx", + Children: h.Fragment(children...), + }, + ) +} + +func PageWithNav(ctx *h.RequestContext, children ...h.Ren) *h.Page { + return RootPage( + ctx, h.Fragment( partials.NavBar(ctx, partials.NavBarProps{ Expanded: false, diff --git a/htmgo-site/pages/discord.go b/htmgo-site/pages/discord.go new file mode 100644 index 0000000..0fc1985 --- /dev/null +++ b/htmgo-site/pages/discord.go @@ -0,0 +1,8 @@ +package pages + +import "github.com/maddalax/htmgo/framework/h" + +func DiscordPage(ctx *h.RequestContext) *h.Page { + ctx.Redirect("https://discord.com/invite/nwQY4h6DtJ", 302) + return h.EmptyPage() +} diff --git a/htmgo-site/pages/docs.go b/htmgo-site/pages/docs.go deleted file mode 100644 index f85e689..0000000 --- a/htmgo-site/pages/docs.go +++ /dev/null @@ -1,66 +0,0 @@ -package pages - -import ( - "github.com/maddalax/htmgo/framework/h" - "htmgo-site/internal/dirwalk" - "htmgo-site/pages/base" - "htmgo-site/partials" - "io/fs" -) - -func DocsPage(ctx *h.RequestContext) *h.Page { - assets := ctx.Get("embeddedMarkdown").(fs.FS) - pages := dirwalk.WalkPages("md/docs", assets) - - return h.NewPage(base.RootPage( - ctx, - h.Div( - h.Class("flex h-full"), - h.Aside( - h.Class("hidden md:block md:min-w-60 text-white overflow-y-auto"), - partials.DocSidebar(pages), - ), - h.Div( - h.Class("flex flex-col flex-1 overflow-hidden"), - partials.NavBar(ctx, partials.NavBarProps{ - Expanded: false, - ShowPreRelease: false, - }), - h.Main( - h.Div( - h.Class("w-full md:hidden bg-neutral-50 overflow-y-auto"), - partials.DocSidebar(pages), - ), - h.Class("overflow-y-auto justify-center md:mx-auto overflow-x-hidden pb-6"), - h.Div( - h.Class("flex flex-col"), - h.Div( - h.Class("flex flex-col justify-center items-center md:mt-6 "), - h.List(pages, func(page *dirwalk.Page, index int) *h.Element { - anchor := partials.CreateAnchor(page.Parts) - return h.Div( - h.Class("border-b border-b-slate-300 w-full pb-8 p-4 md:px-0 -mb-2"), - MarkdownContent(ctx, page.FilePath, anchor), - h.Div( - h.Class("ml-4 pl-1 mt-2 bg-rose-200"), - h.If(anchor == "core-concepts-partials", - h.GetPartial(partials.CurrentTimePartial, "load, every 1s"), - ), - ), - ) - }), - ), - h.Div( - h.Class("flex justify-center items-center mt-6"), - h.A( - h.Text("Back to Top"), - h.Class("py-2 px-3 bg-slate-800 rounded text-white"), - h.Href("#quick-start-introduction"), - ), - ), - ), - ), - ), - ), - )) -} diff --git a/htmgo-site/pages/docs/base.go b/htmgo-site/pages/docs/base.go new file mode 100644 index 0000000..2116a9a --- /dev/null +++ b/htmgo-site/pages/docs/base.go @@ -0,0 +1,159 @@ +package docs + +import ( + "fmt" + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/pages/base" + "htmgo-site/partials" + "strings" +) + +func Title(title string) *h.Element { + return h.H1( + h.Text(title), + h.Class("text-2xl font-bold"), + ) +} + +func SubTitle(title string) *h.Element { + return h.H2( + h.Text(title), + h.Class("text-xl font-bold"), + ) +} + +func StepTitle(title string) *h.Element { + return h.H2( + h.Text(title), + h.Class("text-lg font-bold"), + ) +} + +func NextStep(classes string, prev *h.Element, next *h.Element) *h.Element { + return h.Div( + h.Class("flex gap-2 justify-between", classes), + prev, + next, + ) +} + +func NextBlock(text string, url string) *h.Element { + return h.A( + h.Href(url), + h.Class("w-[50%] border border-slate-300 p-4 rounded text-right hover:border-blue-400 cursor-pointer"), + h.P( + h.Text("Next"), + h.Class("text-slate-600 text-sm"), + ), + h.P( + h.Text(text), + h.Class("text-blue-500 hover:text-blue-400"), + ), + ) +} + +func PrevBlock(text string, url string) *h.Element { + return h.A( + h.Href(url), + h.Class("w-[50%] border border-slate-300 p-4 rounded text-left hover:border-blue-400 cursor-pointer"), + h.P( + h.Text("Previous"), + h.Class("text-slate-600 text-sm"), + ), + h.P( + h.Text(text), + h.Class("text-blue-500 hover:text-blue-400"), + ), + ) +} + +func Image(src string) *h.Element { + return h.Img( + h.Src(src), + h.Class("rounded w-full"), + ) +} + +func Text(text string) *h.Element { + split := strings.Split(text, "\n") + return h.Div( + h.Class("flex flex-col gap-2 leading-relaxed text-slate-900 break-words"), + h.List(split, func(item string, index int) *h.Element { + return h.P( + h.UnsafeRaw(item), + ) + }), + ) +} + +func HelpText(text string) *h.Element { + return h.Div( + h.Class("text-slate-600 text-sm"), + h.UnsafeRaw(text), + ) +} + +func Link(text string, href string, additionalClasses ...string) *h.Element { + additionalClasses = append(additionalClasses, "text-blue-500 hover:text-blue-400") + return h.A( + h.Href(href), + h.Text(text), + h.Class( + additionalClasses..., + ), + ) +} + +func DocPage(ctx *h.RequestContext, children ...h.Ren) *h.Page { + + title := "htmgo" + for _, section := range sections { + for _, page := range section.Pages { + if page.Path == ctx.Request.URL.Path { + title = fmt.Sprintf("Docs - %s", page.Title) + break + } + } + } + + return base.ConfigurableRootPage( + ctx, + base.RootPageProps{ + Title: title, + Description: "build simple and scalable systems with go + htmx", + Children: h.Div( + h.Class("flex h-full"), + h.Aside( + h.Class("hidden md:block md:min-w-60 text-white overflow-y-auto"), + DocSidebar(), + ), + h.Div( + h.Class("flex flex-col flex-1 overflow-hidden"), + partials.NavBar(ctx, partials.NavBarProps{ + Expanded: false, + ShowPreRelease: false, + }), + h.Main( + h.Div( + h.Class("w-full md:hidden bg-neutral-50 overflow-y-auto mb-4 border-b border-b-slate-300"), + DocSidebar(), + ), + h.Class("overflow-y-auto overflow-x-hidden pb-6 items-center w-full"), + h.Div( + h.Class("flex flex-col mx-auto"), + h.Div( + h.Class("flex flex-col justify-center items-center md:mt-6 mx-auto"), + h.Div( + h.Class( + "w-full flex flex-col max-w-[90vw] md:max-w-[65vw] xl:max-w-4xl", + ), + h.Children(children...), + ), + ), + ), + ), + ), + ), + }, + ) +} diff --git a/htmgo-site/pages/docs/config/htmgo-config.go b/htmgo-site/pages/docs/config/htmgo-config.go new file mode 100644 index 0000000..ad3e139 --- /dev/null +++ b/htmgo-site/pages/docs/config/htmgo-config.go @@ -0,0 +1,52 @@ +package config + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func HtmgoConfig(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Htmgo Config"), + Text(` + Certain aspects of htmgo can be configured via a htmgo.yml file in the root of your project. + Here is an example configuration file: + `), + ui.CodeSnippet(ui.CodeSnippetProps{ + Code: htmgoConfig, + Lang: "yaml", + HideLineNumbers: true, + }), + NextStep( + "mt-4", + PrevBlock("Formatter", DocPath("/misc/formatter")), + NextBlock("Examples", "/examples"), + ), + ), + ) +} + +const htmgoConfig = ` +# 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"] + +# files or directories to ignore when automatically registering routes for pages +# supports glob patterns through https://github.com/bmatcuk/doublestar +automatic_page_routing_ignore: ["root.go"] + +# files or directories to ignore when automatically registering routes for partials +# supports glob patterns through https://github.com/bmatcuk/doublestar +automatic_partial_routing_ignore: [] +` diff --git a/htmgo-site/pages/docs/control/if-else.go b/htmgo-site/pages/docs/control/if-else.go new file mode 100644 index 0000000..00f961e --- /dev/null +++ b/htmgo-site/pages/docs/control/if-else.go @@ -0,0 +1,73 @@ +package control + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func IfElse(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("If / Else Statements"), + Text(` + If / else statements are useful when you want to conditionally render attributes or elements / components. + htmgo provides a couple of utilities to do so: + `), + Text("Example: Rendering an icon if the task is complete"), + ui.GoCodeSnippet(IfElseExample), + Text("Example: Using ternary operator to call different partials based on a condition"), + ui.GoCodeSnippet(TenaryExample), + Text(`Example: Rendering multiple classes based on a condition`), + ui.GoCodeSnippet(ConditionalClassExample), + Text("Example: Rendering a single class based on a condition"), + ui.GoCodeSnippet(ClassIfElseExample), + Text("Example: Rendering different elements based on a condition"), + ui.GoCodeSnippetSingleLine(IfElseExample2), + Text(` + Note: This will execute both EditTaskForm and ViewTask, no matter if the condition is true or false, since a function is being called here. + If you do not want to call the function at all unless the condition is true, use h.IfElseLazy + `), + ui.GoCodeSnippetSingleLine(IfElseExample3), + NextStep( + "mt-4", + PrevBlock("Raw HTML", DocPath("/core-concepts/raw-html")), + NextBlock("Rendering Lists", DocPath("/control/loops")), + ), + ), + ) +} + +const IfElseExample = ` +h.Div( + h.If( + task.CompletedAt != nil, + CompleteIcon() + ) +) +` + +const TenaryExample = `h.Div( + h.PostPartialWithQs( + h.Ternary(!editing, StartEditing, SaveEditing), + h.NewQs("id", record.Id), + ), +) +` + +const ConditionalClassExample = `h.ClassX("w-10 h-10 border rounded-full", map[string]bool { + "border-green-500": task.CompletedAt != nil, + "border-slate-400": task.CompletedAt == nil, +})` + +const IfElseExample2 = `h.IfElse(editing, EditTaskForm(), ViewTask())` + +const IfElseExample3 = `h.IfElseLazy(editing, EditTaskForm, ViewTask)` + +const ClassIfElseExample = ` +h.Div( + h.ClassIf(task.CompletedAt != nil, "border-green-500"), +) +` diff --git a/htmgo-site/pages/docs/control/loops.go b/htmgo-site/pages/docs/control/loops.go new file mode 100644 index 0000000..4f824f0 --- /dev/null +++ b/htmgo-site/pages/docs/control/loops.go @@ -0,0 +1,55 @@ +package control + +import . "htmgo-site/pages/docs" +import "htmgo-site/ui" +import "github.com/maddalax/htmgo/framework/h" + +func Loops(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Loops / Dealing With Lists"), + Text(` + Very commonly you will need to render a list or slice of items onto the page. + Frameworks generally solve this in different ways, such as React uses regular JS .map function to solve it. + htmgo provides a couple of utilities to do so: + `), + Text("Example: Rendering a list of tasks"), + ui.GoCodeSnippet(ListExample), + Text("Example: Rendering a map"), + ui.GoCodeSnippet(MapExample), + NextStep( + "mt-4", + PrevBlock("Conditionals", DocPath("/control/if-else")), + NextBlock("Adding Interactivity", DocPath("/interactivity/swapping")), + ), + ), + ) +} + +const ListExample = ` +var items = []string{"item1", "item2", "item3"} +h.List(items, func(item string, index int) *h.Element { + if tab == TabComplete && item.CompletedAt == nil { + return h.Empty() + } + return h.Div( + h.Text(item), + ) +}) +` + +const MapExample = ` +var values = map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", +} +h.IterMap(values, func(key string, value string) *h.Element { + return h.Div( + h.Text(key), + h.Text(value), + ) +}) +` diff --git a/htmgo-site/pages/docs/core-concepts/components.go b/htmgo-site/pages/docs/core-concepts/components.go new file mode 100644 index 0000000..cb8f536 --- /dev/null +++ b/htmgo-site/pages/docs/core-concepts/components.go @@ -0,0 +1,41 @@ +package core_concepts + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func Components(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Components"), + Text(` + Components are re-usable bits of logic to render HTML. Similar to how in React components are Javascript functions, in htmgo, components are pure go functions. + A component can be pure, or it can have data fetching logic inside of it. Since htmgo uses htmx for interactivity, there is NO re-rendering of your UI automatically from the framework, which means you can safely put data fetching logic inside of components since you can be sure they will only be called by your own code. + `), + ComponentExample(), + NextStep( + "mt-4", + PrevBlock("Partials", DocPath("/core-concepts/partials")), + NextBlock("Tags and Attributes", DocPath("/core-concepts/tags-and-attributes")), + ), + ), + ) +} + +func ComponentExample() *h.Element { + return h.Div( + Text("Example:"), + ui.GoCodeSnippet(PagesSnippet), + Text(` + My card component here fetches all my tasks I have on my list, and renders each task. + If you are familiar with React, then you would likely place this fetch logic inside of a useEffect or (useQuery library) so it is not constantly re-fetched as the component re-renders. + With htmgo, the only way to update content on the page is to use htmx to swap out the content from loading a partial. Therefore you control exactly when this card component is called, not the framework behind the scenes. + + You'll learn more about swapping in the next few pages. + `), + ) +} diff --git a/htmgo-site/pages/docs/core-concepts/pages.go b/htmgo-site/pages/docs/core-concepts/pages.go new file mode 100644 index 0000000..bf79d57 --- /dev/null +++ b/htmgo-site/pages/docs/core-concepts/pages.go @@ -0,0 +1,113 @@ +package core_concepts + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +var ExcludeRootSnippet = `automatic_page_routing_ignore: ["pages/root.go"]` + +var AbstractedRootPageUsageSnippet = `func UserPage(ctx *h.RequestContext) *h.Page { + return base.RootPage( + h.Div( + h.Pf("User ID: %s", ctx.Param("id")), + ), +}` + +var RootPageSnippet = `func RootPage(children ...h.Ren) *h.Page { + return h.NewPage( + h.Html( + h.HxExtension(h.BaseExtensions()), + h.Head( + h.Meta("viewport", "width=device-width, initial-scale=1"), + h.Link("/public/main.css", "stylesheet"), + h.Script("/public/htmgo.js"), + ), + h.Body( + h.Class("bg-stone-50 min-h-screen overflow-x-hidden"), + ui.NavBar(), + h.Fragment(children...), + ), + ) + ) +} +` + +var PagesSnippet = `// route will be automatically registered based on the file name +func HelloHtmgoPage(ctx *h.RequestContext) *h.Page { + return h.NewPage( + h.Html( + h.HxExtension(h.BaseExtensions()), + h.Head( + h.Link("/public/main.css", "stylesheet"), + h.Script("/public/htmgo.js"), + ), + h.Body( + h.Pf("Hello, htmgo!"), + ), + ), + ) +}` + +func Pages(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Pages"), + Text(` + Pages are the entry point of an htmgo application. + A simple page may look like: + `), + ui.GoCodeSnippet(PagesSnippet), + h.Text(` + htmgo uses std http with chi router as its web server, *h.RequestContext is a thin wrapper around *http.Request. + A page must return *h.Page, and accept *h.RequestContext as a parameter + `), + autoRegistration(), + tips(), + NextStep( + "mt-4", + PrevBlock("Getting Started", DocPath("/installation")), + NextBlock("Partials", DocPath("/core-concepts/partials")), + ), + ), + ) +} + +func autoRegistration() *h.Element { + return h.Div( + h.Class("flex flex-col gap-2"), + SubTitle("Auto Registration"), + Text(` + htmgo uses file based routing. This means that we will automatically generate and register your routes with chi based on the files you have in the 'pages' directory. + For example, if you have a directory structure like so below, it will get registered into chi router as follows: + + index.go -> /index + users.go -> /users + users.$id.go -> /users/:id + `), + HelpText(`Note: id parameter can be accessed in your page with ctx.Param("id")`), + Text(` + You may put any functions you like in your pages file, auto registration will ONLY register functions that return *h.Page + `), + ) +} + +func tips() *h.Element { + return h.Div( + h.Class("flex flex-col gap-2"), + SubTitle("Tips:"), + Text(` + Generally it is it recommended to abstract common parts of your page into its own component and re-use it, such as script tags, including styling, etc. + Example: + `), + ui.GoCodeSnippet(RootPageSnippet), + Text("Usage:"), + ui.GoCodeSnippet(AbstractedRootPageUsageSnippet), + Text("You need to then update htmgo.yml to exclude that file from auto registration"), + ui.SingleLineBashCodeSnippet(ExcludeRootSnippet), + HelpText("In this example, my root page is in a file called root.go in the pages dir, so I need to exclude it from auto registration, otherwise htmgo wil try to generate a route for it."), + ) +} diff --git a/htmgo-site/pages/docs/core-concepts/partials.go b/htmgo-site/pages/docs/core-concepts/partials.go new file mode 100644 index 0000000..3e24697 --- /dev/null +++ b/htmgo-site/pages/docs/core-concepts/partials.go @@ -0,0 +1,84 @@ +package core_concepts + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/partials" + "htmgo-site/ui" +) + +var PartialsSnippet = `func CurrentTimePartial(ctx *h.RequestContext) *h.Partial { + now := time.Now() + return h.NewPartial( + h.Div( + h.Pf("The current time is %s", now.Format(time.RFC3339)), + ), + ) +}` + +var examplePageSnippet = `func CurrentTimePage(ctx *h.RequestContext) *h.Page { + return RootPage( + h.GetPartial(partials.CurrentTimePartial, "load, every 1s") + ) +}` + +var examplePartialSnippet = `func CurrentTimePartial(ctx *h.RequestContext) *h.Partial { + now := time.Now() + return h.NewPartial( + h.Div( + h.Pf("The current time is %s", now.Format(time.RFC3339)), + ), + ) +}` + +func Partials(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Partials"), + Text(` + Partials are where things get interesting. + Partials allow you to start adding interactivity to your website by swapping in content, setting headers, redirecting, etc. + Partials have a similar structure to pages. A simple partial may look like: + `), + ui.GoCodeSnippet(PartialsSnippet), + h.Text(` + This will get automatically registered in the same way that pages are registered, based on the file path. + This allows you to reference partials directly via the function itself when rendering them, instead of worrying about the route. + `), + example(), + NextStep( + "mt-4", + PrevBlock("Pages", DocPath("/core-concepts/pages")), + NextBlock("Components", DocPath("/core-concepts/components")), + ), + ), + ) +} + +func example() *h.Element { + return h.Div( + h.Class("flex flex-col gap-2"), + SubTitle("Simple Example"), + Text(` + I want to build a page that renders the current time, updating every second. Here is how that may look: + `), + h.Pf( + "pages/time.go", + h.Class("font-semibold"), + ), + ui.GoCodeSnippet(examplePageSnippet), + h.Pf( + "partials/time.go", + h.Class("font-semibold"), + ), + ui.GoCodeSnippet(examplePartialSnippet), + Text( + `When the page load, the partial will be loaded in via htmx, and then swapped in every 1 second. + With this little amount of code and zero written javascript, you have a page that shows the current time and updates every second.`), + h.Div( + h.GetPartial(partials.CurrentTimePartial, "load, every 1s"), + ), + ) +} diff --git a/htmgo-site/pages/docs/core-concepts/raw-html.go b/htmgo-site/pages/docs/core-concepts/raw-html.go new file mode 100644 index 0000000..337da90 --- /dev/null +++ b/htmgo-site/pages/docs/core-concepts/raw-html.go @@ -0,0 +1,45 @@ +package core_concepts + +import "htmgo-site/ui" +import "github.com/maddalax/htmgo/framework/h" +import . "htmgo-site/pages/docs" + +func RawHtml(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Raw HTML"), + Text(` + In some cases, you may want to render raw html instead of using htmgo's functions. + This can be done by using the following methods: + `), + Text("Rendering raw html:"), + ui.GoCodeSnippetSingleLine(RawHtmlExample), + Text("Rendering with formatting:"), + ui.GoCodeSnippetSingleLine(RawHtmlExample2), + Text("Rendering a script:"), + ui.GoCodeSnippetSingleLine(RawHtmlExample3), + Text(` + Important: Be careful when using these methods, these methods do not escape the HTML content + and should never be used with user input unless you have sanitized the input. + `), + h.P( + h.Text("Sanitizing input can be done using "), + Link("html.EscapeString", "https://pkg.go.dev/html#EscapeString"), + h.Text(" or by using "), + Link("bluemonday", "https://github.com/microcosm-cc/bluemonday."), + h.Text(" for more control over sanitization."), + ), + NextStep( + "mt-4", + PrevBlock("Tags and Attributes", DocPath("/core-concepts/tags-and-attributes")), + NextBlock("Conditionals", DocPath("/control/if-else")), + ), + ), + ) +} + +const RawHtmlExample = `h.UnsafeRaw("
    Raw HTML
    ")` +const RawHtmlExample2 = `h.UnsafeRawF("
    %s
    ", "Raw HTML")` +const RawHtmlExample3 = `h.UnsafeRawScript("alert('Hello World')")` diff --git a/htmgo-site/pages/docs/core-concepts/tags-and-attributes.go b/htmgo-site/pages/docs/core-concepts/tags-and-attributes.go new file mode 100644 index 0000000..8b6634f --- /dev/null +++ b/htmgo-site/pages/docs/core-concepts/tags-and-attributes.go @@ -0,0 +1,84 @@ +package core_concepts + +import "htmgo-site/ui" +import "github.com/maddalax/htmgo/framework/h" +import . "htmgo-site/pages/docs" + +func TagsAndAttributes(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Tags and Attributes"), + Text(` + In htmgo, html is built using a set of functions that return *h.Element. + These functions are all defined in the 'h' package in htmgo/framework + htmgo provides methods to render most if not all html tags and attributes. + `), + Text(`Example:`), + ui.GoCodeSnippet(TagExample), + Text(` + All methods can be found in the 'h' package in htmgo/framework + `), + Text("h.Tag and h.Attribute are available to use when you need to render a tag or attribute that htmgo does not provide a method for."), + ui.GoCodeSnippet(TagExampleUsingTagFunc), + Text(` + Attributes are one of the main ways we can add interactivity to the pages with htmx. + htmgo provides various methods to add attributes to elements, as well as adding attributes based on a condition. + `), + ui.GoCodeSnippet(AttributeExample), + HelpText("In this example we are conditionally adding an attribute based on if there is an error on not, you'll learn more about conditionals in the next few pages."), + Text("Example using htmx attributes:"), + ui.GoCodeSnippet(HxAttributeExample), + NextStep( + "mt-4", + PrevBlock("Components", DocPath("/core-concepts/components")), + NextBlock("Raw HTML", DocPath("/core-concepts/raw-html")), + ), + ), + ) +} + +const TagExample = `h.Div( + h.Class("flex gap-2"), + h.Button( + h.Text("Submit"), + ), +) +` + +const TagExampleUsingTagFunc = `h.Tag("my-custom-tag", + h.Class("flex gap-2"), + h.Button( + h.Attribute("x-custom-attr", "my-value"), + h.Text("Submit"), + ), +) +` + +const AttributeExample = `h.Div( + h.Class("flex gap-2"), + h.Id("my-div"), + h.If( + error != "", + h.Class("p-4 bg-rose-400 text-white rounded"), + ) +) +` + +const HxAttributeExample = `h.Tr( + h.Class("flex gap-2"), + h.HxInclude("input") + h.Td( + h.Input("text", + h.Class("p-4 rounded"), + h.Placeholder("Type something"), + h.Name("my-input"), + ) + ), + h.Td( + h.Button( + h.Text("Submit"), + ) + ), +)` diff --git a/htmgo-site/pages/docs/htmx-extensions/mutation-error.go b/htmgo-site/pages/docs/htmx-extensions/mutation-error.go new file mode 100644 index 0000000..d4ee6fa --- /dev/null +++ b/htmgo-site/pages/docs/htmx-extensions/mutation-error.go @@ -0,0 +1,42 @@ +package htmx_extensions + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func MutationError(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Mutation Error"), + Text(` + The 'mutation-error' extension allows you to trigger an event when a request returns a >= 400 status code. + This is useful for things such as letting a child element (such as a button) inside a form know there was an error. + `), + Text(`Example:`), + ui.GoCodeSnippet(MutationErrorExample), + Text(`It can also be used on children elements that do not make an xhr request, if you combine it with the TriggerChildren extension.`), + NextStep( + "mt-4", + PrevBlock("Trigger Children", DocPath("/htmx-extensions/trigger-children")), + NextBlock("Tailwind Intellisense", DocPath("/misc/tailwind-intellisense")), + ), + ), + ) +} + +const MutationErrorExample = ` +h.Form( + h.HxTriggerChildren(), + h.HxMutationError( + js.Alert("An error occurred"), + ), + h.Button( + h.Type("submit"), + h.Text("Submit"), + ), +) +` diff --git a/htmgo-site/pages/docs/htmx-extensions/overview.go b/htmgo-site/pages/docs/htmx-extensions/overview.go new file mode 100644 index 0000000..46c83b7 --- /dev/null +++ b/htmgo-site/pages/docs/htmx-extensions/overview.go @@ -0,0 +1,63 @@ +package htmx_extensions + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func Overview(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("HTMX Extensions"), + Text(` + htmgo provides a few extra htmx extensions to make common tasks easier. + Some of these extensions are optional, and some of these are required for htmgo to work correctly. + `), + Text(` + The following extensions are provided by htmgo: + `), + Link("Trigger Children", "/docs/htmx-extensions/trigger-children"), + Link("Mutation Error", "/docs/htmx-extensions/mutation-error"), + Link("Path Deps", "https://github.com/bigskysoftware/htmx-extensions/blob/main/src/path-deps/README.md"), + h.P( + h.Class("mt-3"), + h.Text("Default extensions should be included in your project by adding the following attribute to your html tag."), + ui.GoCodeSnippet(DefaultExtensions), + h.Text("If you need to combine multiple extensions, you can use:"), + ui.GoCodeSnippet(CombineMultipleExtensions), + h.Text("or"), + ui.GoCodeSnippet(CombineMultipleExtensions2), + ), + Text(` + Important: h.BaseExtensions will add the 'htmgo' extension, which is a required extension for inline scripts to work properly, please always include it in your project. + `), + NextStep( + "mt-4", + PrevBlock("Pushing Data", DocPath("/pushing-data/sse")), + NextBlock("Trigger Children", DocPath("/htmx-extensions/trigger-children")), + ), + ), + ) +} + +const DefaultExtensions = ` +h.Html( + h.HxExtension(h.BaseExtensions()) +) +` + +const CombineMultipleExtensions = ` +h.HxExtensions( + h.BaseExtensions(), "my-extension" +) +` + +const CombineMultipleExtensions2 = ` +h.JoinExtensions( + h.HxExtension("sse"), + h.HxExtension("my-extension"), +) +` diff --git a/htmgo-site/pages/docs/htmx-extensions/trigger-children.go b/htmgo-site/pages/docs/htmx-extensions/trigger-children.go new file mode 100644 index 0000000..de9a48e --- /dev/null +++ b/htmgo-site/pages/docs/htmx-extensions/trigger-children.go @@ -0,0 +1,37 @@ +package htmx_extensions + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" +) + +func TriggerChildren(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Trigger Children"), + Text(` + The 'trigger-children' extension allows you to trigger an event on all children and siblings of an element. + This is useful for things such as letting a child element (such as a button) inside a form know the form was submitted + `), + Link("View Example", "https://htmgo.dev/examples/form"), + HelpText(`In this example: The trigger-children extension will trigger hx-before-request and hx-after-request on all children of the form when the form is submitted, and the button reacts to that by showing a loading state.`), + NextStep( + "mt-4", + PrevBlock("HTMX Extensions", DocPath("/htmx-extensions/overview")), + NextBlock("Mutation Error", DocPath("/htmx-extensions/mutation-error")), + ), + ), + ) +} + +const TriggerChildrenExample = ` +func MyForm() *h.Element { + return h.Form( + h.Button( + h.Text("Submit"), + ), + ) +} +` diff --git a/htmgo-site/pages/docs/index.go b/htmgo-site/pages/docs/index.go new file mode 100644 index 0000000..eaa56a3 --- /dev/null +++ b/htmgo-site/pages/docs/index.go @@ -0,0 +1,8 @@ +package docs + +import "github.com/maddalax/htmgo/framework/h" + +func Index(ctx *h.RequestContext) *h.Page { + ctx.Redirect("/docs/introduction", 302) + return h.EmptyPage() +} diff --git a/htmgo-site/pages/docs/installation.go b/htmgo-site/pages/docs/installation.go new file mode 100644 index 0000000..93ef2ab --- /dev/null +++ b/htmgo-site/pages/docs/installation.go @@ -0,0 +1,48 @@ +package docs + +import ( + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/ui" +) + +func Installation(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Getting Started"), + h.Ul( + h.Text("Prerequisites:"), + h.Class("list-disc list-inside"), + h.Li( + Link("Go 1.2X or above", "https://go.dev/doc/install"), + ), + h.Li( + h.Text("Familiarity with "), + Link("https://htmx.org", "https://htmx.org"), + ), + ), + HelpText("If you have not read the htmx docs, please do so before continuing, many of the concepts htmgo uses will become clearer."), + StepTitle("1. Install htmgo"), + ui.SingleLineBashCodeSnippet(`GOPROXY=direct go install github.com/maddalax/htmgo/cli/htmgo@latest`), + Text("If you are using Windows, you will need to use the following command instead:"), + ui.SingleLineBashCodeSnippet(`set GOPROXY=direct && go install github.com/maddalax/htmgo/cli/htmgo@latest`), + HelpText("Make sure GOPROXY=direct is set, otherwise you may have issues."), + StepTitle("2. Create new project"), + ui.SingleLineBashCodeSnippet(`htmgo template`), + HelpText("this will ask you for a new app name, and it will clone our starter template to a new directory it creates with your app name."), + StepTitle("3. Running the dev server"), + ui.SingleLineBashCodeSnippet(`htmgo watch`), + HelpText("htmgo has built in live reload on the dev server, to use this, run this command in the root of your project"), + HelpText("If you prefer to run the dev server yourself (no live reload), use `htmgo run`"), + StepTitle("4. Building for production"), + ui.SingleLineBashCodeSnippet(`htmgo build`), + HelpText("it will be output to `./dist`"), + NextStep( + "mt-4", + PrevBlock("Introduction", DocPath("/introduction")), + NextBlock("Core Concepts", DocPath("/core-concepts/pages")), + ), + ), + ) +} diff --git a/htmgo-site/pages/docs/interactivity/eval-commands.go b/htmgo-site/pages/docs/interactivity/eval-commands.go new file mode 100644 index 0000000..12fecd2 --- /dev/null +++ b/htmgo-site/pages/docs/interactivity/eval-commands.go @@ -0,0 +1,83 @@ +package interactivity + +import ( + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/ui" +) + +import . "htmgo-site/pages/docs" + +func EvalCommands(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Eval Commands"), + Text(` + Now that we've learned how about events/commands, I want to highlight a few useful commands. + One in particular is EvalCommands, which allows you to evaluate any command against any element just by referencing it in Go. + `), + SubTitle("Referencing an element directly"), + Text("Example: Setting the text of an element on click of another element"), + ui.GoCodeSnippet(EvalCommandsSnippet), + Text( + `We are calling js.EvalCommands with the text variable and the command to toggle the text of the element. + This always you to run any commands against any element, without having to query for it via a selector. + `, + ), + h.P( + h.A( + h.Class("underline text-blue-500"), + h.Href("/examples/js-hide-children-on-click"), + h.Text("View the demo"), + ), + h.Text(" for more details on what this could be used for."), + ), + h.Div( + h.Class("mt-4"), + ), + SubTitle("Using a selector"), + Text("If needed, you can query by selector"), + ui.GoCodeSnippet(EvalCommandsSnippetWithSelector), + NextStep( + "mt-4", + PrevBlock("Events / Commands", DocPath("/interactivity/events")), + NextBlock("Caching Components", DocPath("/performance/caching-globally")), + ), + ), + ) +} + +const EvalCommandsSnippetWithSelector = ` +func MyComponent(ctx *h.RequestContext) *h.Element { + text := h.Pf("Text Before", h.Id("my-element")) + return h.Div( + h.Button( + h.Text("Toggle Text"), + h.OnClick( + js.EvalCommandsOnSelector( + "#my-element", + js.ToggleText("Text Before", "Text After"), + ), + ), + ), + text, + ) +} +` + +var EvalCommandsSnippet = `func MyComponent(ctx *h.RequestContext) *h.Element { + text := h.Pf("Text Before") + return h.Div( + h.Button( + h.Text("Toggle Text"), + h.OnClick( + js.EvalCommands( + text, + js.ToggleText("Text Before", "Text After"), + ), + ), + ), + text, + ) +}` diff --git a/htmgo-site/pages/docs/interactivity/events.go b/htmgo-site/pages/docs/interactivity/events.go new file mode 100644 index 0000000..2fc5906 --- /dev/null +++ b/htmgo-site/pages/docs/interactivity/events.go @@ -0,0 +1,126 @@ +package interactivity + +import ( + "fmt" + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/ui" +) + +import . "htmgo-site/pages/docs" + +func EventsAndCommands(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Events Handler / Commands"), + Text(` + In some cases, you need to update elements client side without having to do a network call. + For this you generally have to target an element with javascript and set an attribute, change the innerHTML, etc. + To make this work while still keeping a pure go feel, htmgo offers a few utility methods to execute various javascript on an element. + `), + Text("Example: When the form is submitted, set the button text to submitting and disable it, and vice versa after submit is done."), + ui.GoCodeSnippet(EventsExample1), + Text(` + The structure of this comes down to: + 1. Add an event handler to the element + 2. Add commands (found in the 'js' package) as children to that event handler + `), + Text(`The current list of event handlers we have utility methods for so far are:`), + ui.CodeSnippet(ui.CodeSnippetProps{ + Code: CurrentHandlersSnippet, + Lang: "bash", + HideLineNumbers: true, + }), + h.P( + h.Text("If there is not an existing method for the event you need, you can use the h.OnEvent method to add a handler for any "), + Link("DOM event", "https://www.w3schools.com/jsref/dom_obj_event.asp"), + h.Text(" or "), + Link("htmx event.", "https://htmx.org/events/"), + ), + Text("If there is not an existing method for the event you need, you can use the h.OnEvent method to add an event handler for any DOM or htmx event."), + ui.GoCodeSnippet(OnEventBlurSnippet), + h.P( + h.Text(`For more details on how they work, see the source for `), + Link("lifecycle.", "https://github.com/maddalax/htmgo/blob/master/framework/h/lifecycle.go"), + h.Text(" Any method that returns *Lifecycle can be used as an event handler, and any method that returns *Command can be used as a command."), + ), + h.P( + h.Text(`The current list of commands supported can be found `), + Link("here.", "https://github.com/maddalax/htmgo/blob/master/framework/js/commands.go"), + ), + HelpText("Note: Each command you attach to the event handler will be passed 'self' and 'event' (if applicable) as arguments. self is the current element, and event is the event object."), + Text("Example: Evaluating arbitrary Javascript"), + ui.GoCodeSnippet(EvalArbitraryJavascriptSnippet), + HelpText("Tips: If you are using Jetbrains IDE's, you can write '// language=js' as a comment above the function call (h.EvalJS) and it will automatically give you syntax highlighting on the raw JS."), + h.P( + h.Text("More examples and usage can be found on the "), + Link("examples page, ", "/examples/js-set-text-on-click"), + h.Text("in the 'Interactivity' section."), + ), + NextStep( + "mt-4", + PrevBlock("Swapping", DocPath("/interactivity/swapping")), + NextBlock("Eval Commands", DocPath("/interactivity/eval-commands")), + ), + ), + ) +} + +const EventsExample1 = ` +func MyForm() *h.Element { + return h.Form( + h.Button( + h.Text("Submit"), + h.HxBeforeRequest( + js.SetDisabled(true), + js.SetText("Submitting..."), + ), + h.HxAfterRequest( + js.SetDisabled(false), + js.SetText("Submit"), + ), + ), + ) +} +` + +var OnEventBlurSnippet = ` +h.Input( + h.OnEvent( + hx.BlurEvent, + js.SetValue("Input was blurred"), + ) +)` + +var EvalArbitraryJavascriptSnippet = fmt.Sprintf(`func MyButton() *h.Element { + return h.Button( + h.Text("Submit"), + h.OnClick( + // make sure you use 'self' instead of 'this' for referencing the current element + h.EvalJs(%s + if(Math.random() > 0.5) { + self.innerHTML = "Success!"; + }%s + ), + ), + ) +}`, "`", "`") + +const CurrentHandlersSnippet = ` +h.OnEvent +h.OnLoad +h.HxBeforeRequest +h.HxOnLoad +h.HxOnAfterSwap +h.OnClick +h.OnSubmit +h.HxBeforeSseMessage +h.HxAfterSseMessage +h.HxOnSseError +h.HxOnSseClose +h.HxOnSseConnecting +h.HxOnSseOpen +h.HxAfterRequest +h.HxOnMutationError +` diff --git a/htmgo-site/pages/docs/interactivity/swapping.go b/htmgo-site/pages/docs/interactivity/swapping.go new file mode 100644 index 0000000..9fd9274 --- /dev/null +++ b/htmgo-site/pages/docs/interactivity/swapping.go @@ -0,0 +1,110 @@ +package interactivity + +import . "htmgo-site/pages/docs" +import "htmgo-site/ui" +import "github.com/maddalax/htmgo/framework/h" + +func Swapping(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Swapping"), + Text(` + Swapping is the process of swapping out the content of an element with another element. + This is the primary way htmgo allows you to add interactivity to your website through htmx. + `), + h.P( + h.Text("The swapping examples below utilize "), + Link("hx-swap-oob", "https://htmx.org/attributes/hx-swap-oob/"), + h.Text(" behind the scenes to swap out the content of an element."), + ), + Text("Example: A simple counter"), + ui.GoCodeSnippet(SwapExample), + Text(` + In this example, when the form is submitted, an HTTP POST will be sent to the server and call CounterPartial. + CounterPartial will then update the count and return it back to the client via h.SwapManyPartial. + The h.SwapManyPartial function is a helper function that allows you to swap out multiple elements on the page. + `), + Text(` + All the routing is handled behind the scenes by htmgo, so you can reference partials directly by their function reference, + instead of having to wire up routes for each partial. + `), + Text(` + Sometimes you may need to pass additional information when calling the partial, such as an id of the current entity you are working with. + This can be done by like so: + `), + Text("Example: Getting the http path to the partial with extra qs parameters"), + ui.GoCodeSnippet(SwapGetPartialPathWithQsExample), + Text("Example: Posting to the partial path on blur"), + ui.GoCodeSnippet(SwapGetPartialPathExampleOnBlur), + h.P( + h.Text("Note: if your swapping is not working as expected, make sure the element you are swapping has an id and it matches. "), + h.Text("For further details on how oob works behind the scenes, see the "), + Link("hx-swap-oob", "https://htmx.org/attributes/hx-swap-oob/"), + h.Text(" docs."), + ), + NextStep( + "mt-4", + PrevBlock("Loops / Dealing With Lists", DocPath("/control/loops")), + NextBlock("Events / Commands", DocPath("/interactivity/events")), + ), + ), + ) +} + +const SwapExample = ` +func CounterPartial(ctx *h.RequestContext) *h.Partial { + count, _ := strconv.ParseInt(ctx.FormValue("count"), 10, 64) + + count++ + + return h.SwapManyPartial( + ctx, + CounterForm(int(count)), + h.ElementIf(count > 10, SubmitButton("New record!")), + ) +} + +func CounterForm(count int) *h.Element { + return h.Form( + h.Id("counter-form"), + h.PostPartial(CounterPartial), + h.Input( + "text", + h.Class("hidden"), + h.Value(count), + h.Name("count"), + ), + h.P( + h.Id("counter"), + h.Name("count"), + h.TextF("Count: %d", count), + ), + h.Button( + h.Type("submit"), + h.Text("Increment"), + ), + ) +} +` + +const SwapGetPartialPathWithQsExample = ` +func MyComponent() *h.Element { + return h.Div( + h.GetPartialPathWithQs( + CounterPartial, + h.NewQs("count", count), + ), + ) +} +` + +const SwapGetPartialPathExampleOnBlur = ` +func MyComponent() *h.Element { + path := h.GetPartialPath(CounterPartial) + return h.Input( + h.Post(path, hx.BlurEvent), + ) +} +` diff --git a/htmgo-site/pages/docs/introduction.go b/htmgo-site/pages/docs/introduction.go new file mode 100644 index 0000000..37fd47d --- /dev/null +++ b/htmgo-site/pages/docs/introduction.go @@ -0,0 +1,48 @@ +package docs + +import ( + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/ui" +) + +const IntroSnippet = `func DocsPage(ctx *h.RequestContext) *h.Page { + pages := dirwalk.WalkPages("md/docs") + return h.NewPage( + h.Div( + h.Class("flex flex-col md:flex-row gap-4"), + DocSidebar(pages), + h.Div( + h.Class("flex flex-col justify-center items-center mt-6"), + h.List(pages, func(page *dirwalk.Page, index int) *h.Element { + return h.Div( + h.Class("border-b border-b-slate-300"), + MarkdownContent(ctx, page), + ) + }), + ), + ), +}` + +func Introduction(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-2"), + Title("Introduction"), + Text(` + htmgo is a lightweight pure go way to build interactive websites / web applications using go & htmx. + We give you the utilities to build html using pure go code in a reusable way (go functions are components) while also providing htmx functions to add interactivity to your app. + `), + ui.GoCodeSnippet(IntroSnippet), + h.P( + Link("The site you are reading now", "https://github.com/maddalax/htmgo/tree/master/htmgo-site"), + h.Text(" was written with htmgo!"), + ), + NextStep( + "mt-4", + h.Div(), + NextBlock("Getting Started", DocPath("/installation")), + ), + ), + ) +} diff --git a/htmgo-site/pages/docs/misc/formatter.go b/htmgo-site/pages/docs/misc/formatter.go new file mode 100644 index 0000000..19242f6 --- /dev/null +++ b/htmgo-site/pages/docs/misc/formatter.go @@ -0,0 +1,63 @@ +package misc + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func Formatter(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Formatter"), + Text(` + htmgo has a built-in formatter that can be used to format htmgo element blocks. + It is available through the 'htmgo' cli tool that is installed with htmgo. + `), + HelpText(`Note: if you have previously installed htmgo, you will need to run GOPROXY=direct go install github.com/maddalax/htmgo/cli/htmgo@latest to update the cli tool.`), + Text("Usage:"), + ui.SingleLineBashCodeSnippet(`htmgo format .`), + HelpText(`This will format all htmgo element blocks in your project recursively.`), + ui.SingleLineBashCodeSnippet(`htmgo format ./my-file.go`), + HelpText(`This will format the file specified.`), + Text("Before:"), + ui.GoCodeSnippet(formatBefore), + Text("After:"), + ui.GoCodeSnippet(formatAfter), + h.Div( + h.Class("hidden md:block w-[800px] h-[800px] rounded"), + Video(), + ), + NextStep( + "mt-4", + PrevBlock("Tailwind Intellisense", DocPath("/misc/tailwind-intellisense")), + NextBlock("Configuration", DocPath("/config/htmgo-config")), + ), + ), + ) +} + +const formatBefore = `h.Div( + h.Class("flex gap-2"), h.Text("hello"), h.Text("world"), +)` + +const formatAfter = `h.Div( + h.Class("flex gap-2"), + h.Text("hello"), + h.Text("world"), +) +` + +func Video() *h.Element { + return h.Video( + h.Tag( + "source", + h.Src("/public/formatter.mp4"), + h.Type("video/mp4"), + ), + h.Controls(), + h.Class("h-full w-full rounded"), + ) +} diff --git a/htmgo-site/pages/docs/misc/tailwind-intellisense.go b/htmgo-site/pages/docs/misc/tailwind-intellisense.go new file mode 100644 index 0000000..e2ffec3 --- /dev/null +++ b/htmgo-site/pages/docs/misc/tailwind-intellisense.go @@ -0,0 +1,44 @@ +package misc + +import ( + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/internal/urlhelper" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func TailwindIntellisense(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Tailwind Intellisense"), + Text(` + Tailwind's language server allows you to specify custom configuration on what it should match to start giving you tailwind intellisense. + `), + Text(`To make this work, you will need to update the tailwind lsp config with the config below:`), + Image("/public/tailwind-intellisense.png"), + Text(`To make this work, you will need to update your Tailwind LSP configuration with what is below:`), + SubTitle("Jetbrains IDE's"), + ui.CodeSnippetFromUrl(urlhelper.ToAbsoluteUrl(ctx, "/public/jetbrains-tailwind.json"), ui.CodeSnippetProps{ + Lang: "json", + HideLineNumbers: true, + }), + Text(` + To find this configuration in GoLand you can go to Settings -> Languages & Frameworks -> Style Sheets -> Tailwind CSS and update the configuration there. + These changes are additive, add these options to your existing Tailwind LSP configuration, instead of replacing the entire file. + `), + SubTitle("Visual Studio Code"), + Text(`For VSCode, you should be able to update your settings.json with the following values:`), + ui.CodeSnippetFromUrl(urlhelper.ToAbsoluteUrl(ctx, "/public/vscode-tailwind.json"), ui.CodeSnippetProps{ + Lang: "json", + HideLineNumbers: true, + }), + NextStep( + "mt-4", + PrevBlock("Mutation Error Extension", DocPath("/htmx-extensions/mutation-error")), + NextBlock("Formatting blocks", DocPath("/misc/formatter")), + ), + ), + ) +} diff --git a/htmgo-site/pages/docs/performance/caching-globally.go b/htmgo-site/pages/docs/performance/caching-globally.go new file mode 100644 index 0000000..c136d2f --- /dev/null +++ b/htmgo-site/pages/docs/performance/caching-globally.go @@ -0,0 +1,83 @@ +package performance + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func CachingGlobally(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Caching Components Globally"), + Text(` + You may want to cache components to improve performance. This is especially useful for components that are expensive to render or make external requests for data. + + When a request is made for a cached component, the component is rendered and stored in memory. Subsequent requests for the same component within the cache duration will return the cached component instead of rendering it again. + + To cache a component in htmgo, we offer two ways, caching globally or caching per key, this section will focus on caching globally, you will learn more about caching per key in the next section: + `), + Text("Methods for caching globally:"), + ui.GoCodeSnippet(CachingMethods), + h.P( + h.Text("For caching components per unique identifier, see "), + Link("Caching Components Per Key", "/docs/performance/caching-per-key"), + h.Text("."), + ), + Text(`Usage:`), + ui.GoCodeSnippet(CachedGloballyExample), + Text(` + We are using CachedT because the component takes one argument, the RequestContext. + If the component takes more arguments, use CachedT2, CachedT3, etc. + `), + Text( + `Important Note: When using h.CachedT and not CachedPerKey, the cached value is stored globally in memory, so it is shared across all requests. + Do not store request-specific data in a cached component. Only cache components that you are OK with all users seeing the same data. + + The arguments passed into cached component DO NOT affect the cache key. You will get the same cached component regardless of the arguments passed in. This is different from what you may be used to from something like React useMemo. + + Ensure the declaration of the cached component is outside the function that uses it. This is to prevent the component from being redeclared on each request. + `), + NextStep( + "mt-4", + PrevBlock("Eval Commands", DocPath("/interactivity/eval-commands")), + NextBlock("Caching Per Key", DocPath("/performance/caching-per-key")), + ), + ), + ) +} + +const CachingMethods = ` +// No arguments passed to the component +h.Cached(duration time.Duration, cb GetElementFunc) +// One argument passed to the component +h.CachedT(duration time.Duration, cb GetElementFunc) +// Two arguments passed to the component +h.CachedT2(duration time.Duration, cb GetElementFunc) +// Three arguments passed to the component +h.CachedT3(duration time.Duration, cb GetElementFunc) +// Four arguments passed to the component +h.CachedT4(duration time.Duration, cb GetElementFunc) +` + +const CachedGloballyExample = ` +func ExpensiveComponent(ctx *h.RequestContext) *h.Element { + // Some expensive call + data := http.Get("https://api.example.com/data") + return h.Div( + h.Text(data), + ) +} + +var CachedComponent = h.CachedT(time.Minute*15, func(ctx *h.RequestContext) *h.Element { + return ExpensiveComponent(ctx) +}) + +func IndexPage(ctx *h.RequestContext) *h.Page { + return h.NewPage( + CachedComponent(ctx), + ) +} +` diff --git a/htmgo-site/pages/docs/performance/caching-per-key.go b/htmgo-site/pages/docs/performance/caching-per-key.go new file mode 100644 index 0000000..da8eb92 --- /dev/null +++ b/htmgo-site/pages/docs/performance/caching-per-key.go @@ -0,0 +1,98 @@ +package performance + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func CachingPerKey(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Caching Components Per Key"), + Text(` + If you need to cache a component per unique identifier, you can use the CachedPerKey functions. + These functions allow you to cache a component by a specific key. This key can be any string that uniquely identifies the user. + Note: I'm using the term 'user' to simply mean a unique identifier. This could be a user ID, session ID, or any other unique identifier. + `), + Text("Methods for caching per key:"), + ui.GoCodeSnippet(CachingMethodsPerKey), + Text(`Usage:`), + ui.GoCodeSnippet(CachedPerKeyExample), + Text(` + We are using CachedPerKeyT because the component takes one argument, the RequestContext. + If the component takes more arguments, use CachedPerKeyT2, CachedPerKeyT3, etc. + `), + Text( + ` + Important Note: + The cached value is stored globally in memory by key, it is shared across all requests. Ensure if you are storing request-specific data in a cached component, you are using a unique key for each user. + The arguments passed into cached component DO NOT affect the cache key. The only thing that affects the cache key is the key returned by the GetElementFuncWithKey function. + Ensure the declaration of the cached component is outside the function that uses it. This is to prevent the component from being redeclared on each request. + `), + NextStep( + "mt-4", + PrevBlock("Caching Globally", DocPath("/performance/caching-globally")), + NextBlock("Pushing Data", DocPath("/pushing-data/sse")), + ), + ), + ) +} + +const CachingMethodsPerKey = ` +// No arguments passed to the component, the component can be cached by a specific key +h.CachedPerKey(duration time.Duration, cb GetElementFuncWithKey) +// One argument passed to the component, the component can be cached by a specific key +h.CachedPerKeyT1(duration time.Duration, cb GetElementFuncWithKey) +// Two argument passed to the component, the component can be cached by a specific key +h.CachedPerKeyT2(duration time.Duration, cb GetElementFuncWithKey) +// Three arguments passed to the component, the component can be cached by a specific key +h.CachedPerKeyT3(duration time.Duration, cb GetElementFuncWithKey) +// Four arguments passed to the component, the component can be cached by a specific key +h.CachedPerKeyT4(duration time.Duration, cb GetElementFuncWithKey) +` + +const CachedPerKeyExample = ` +var CachedUserDocuments = h.CachedPerKeyT(time.Minute*15, func(ctx *h.RequestContext) (string, h.GetElementFunc) { + userId := getUserIdFromSession(ctx) + return userId, func() *h.Element { + return UserDocuments(ctx) + } +}) + +func UserDocuments(ctx *h.RequestContext) *h.Element { + docService := NewDocumentService(ctx) + // Expensive call + docs := docService.getDocuments() + return h.Div( + h.Class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"), + h.List(docs, func(doc Document, index int) *h.Element { + return h.Div( + h.Class("p-4 bg-white border border-gray-200 rounded-md"), + h.H3(doc.Title), + h.P(doc.Description), + ) + }), + ) +} + +func MyPage(ctx *h.RequestContext) *h.Page { + + // Note this is not a real way to create a context, just an example + user1 := &h.RequestContext{ + Session: "user_1_session", + } + + user2 := &h.RequestContext{ + Session: "user_2_session", + } + + // Different users will get different cached components + return h.NewPage( + CachedUserDocuments(user1), + CachedUserDocuments(user2), + ) +} +` diff --git a/htmgo-site/pages/docs/pushing-data/sse.go b/htmgo-site/pages/docs/pushing-data/sse.go new file mode 100644 index 0000000..7b3150a --- /dev/null +++ b/htmgo-site/pages/docs/pushing-data/sse.go @@ -0,0 +1,87 @@ +package pushing_data + +import ( + "github.com/maddalax/htmgo/framework/h" + . "htmgo-site/pages/docs" + "htmgo-site/ui" +) + +func ServerSentEvents(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Server Sent Events (SSE)"), + Text(` + htmgo supports server-sent events (SSE) out of the box. + This allows you to push data from the server to the client in real-time. + `), + h.P( + h.Text("Example of this can be found in the "), + Link("examples/chat", "examples/chat"), + h.Text(" project."), + ), + SubTitle("How it works"), + Text(`1. The client sends a request to the server to establish a connection. + 2. The server holds the connection open and sends data (in our case, most likely elements) to the client whenever there is new data to send. + 3. The htmgo SSE extension uses hx-swap-oob to swap out the elements that the server sends. + `), + HelpText("Note: SSE is unidirectional (the server can only send data to the client). For the client to send data to the server, normal xhr behavior should be used (form submission, triggers, etc)."), + Text(`Usage:`), + Text("Add the SSE connection attribute and the path to the handler that will handle the connection."), + ui.GoCodeSnippet(SseConnectAttribute), + Text("The following Event Handlers can be used to react to SSE connections."), + ui.GoCodeSnippet(SseEventHandlers), + Text("Example: Adding an event listener handle SSE errors."), + ui.GoCodeSnippet(SseErrorHandlingExample), + Text("Example: Clearing the input field after sending a message."), + ui.GoCodeSnippet(SseClearInputExample), + NextStep( + "mt-4", + PrevBlock("Caching Per Key", DocPath("/performance/caching-per-key")), + NextBlock("HTMX extensions", DocPath("/htmx-extensions/overview")), + ), + ), + ) +} + +const SseConnectAttribute = ` +h.Attribute("sse-connect", fmt.Sprintf("/chat/%s", roomId)) +` + +const SseEventHandlers = ` +h.HxOnSseOpen +h.HxBeforeSseMessage +h.HxAfterSseMessage +h.HxOnSseError +h.HxOnSseClose +h.HxOnSseConnecting +` + +const SseErrorHandlingExample = ` +h.HxOnSseError( + js.EvalJs(fmt.Sprintf(" + const reason = e.detail.event.data + if(['invalid room', 'no session', 'invalid user'].includes(reason)) { + window.location.href = '/?roomId=%s'; + } else if(e.detail.event.code === 1011) { + window.location.reload() + } else if (e.detail.event.code === 1008 || e.detail.event.code === 1006) { + window.location.href = '/?roomId=%s'; + } else { + console.error('Connection closed:', e.detail.event) + } +", roomId, roomId)), +), +` + +const SseClearInputExample = ` +func MessageInput() *h.Element { + return h.Input("text", + h.Id("message-input"), + h.Required(), + h.HxAfterSseMessage( + js.SetValue(""), + ), + ) +}` diff --git a/htmgo-site/pages/docs/related-projects.go b/htmgo-site/pages/docs/related-projects.go new file mode 100644 index 0000000..359039b --- /dev/null +++ b/htmgo-site/pages/docs/related-projects.go @@ -0,0 +1,35 @@ +package docs + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func RelatedProjects(ctx *h.RequestContext) *h.Page { + return DocPage( + ctx, + h.Div( + h.Class("flex flex-col gap-3"), + Title("Other languages and related projects"), + Text(` + If you're not a Go user but are interested in the idea of what htmgo is, you might want to check out these other projects: + `), + h.Ul( + h.Class("font-bold"), + h.Text("Python:"), + h.Class("list-disc list-inside"), + h.Li( + h.P( + h.Class("font-normal"), + Link("fastht.ml", "https://fastht.ml"), + h.Text(" - Modern web applications in pure Python, Built on solid web foundations, not the latest fads - with FastHTML you can get started on anything from simple dashboards to scalable web applications in minutes."), + ), + ), + ), + NextStep( + "mt-4", + PrevBlock("Tailwind Intellisense", "/docs/misc/tailwind-intellisense"), + NextBlock("Adding Interactivity", "/docs/interactivity/swapping"), + ), + ), + ) +} diff --git a/htmgo-site/pages/docs/sidebar.go b/htmgo-site/pages/docs/sidebar.go new file mode 100644 index 0000000..8a524ab --- /dev/null +++ b/htmgo-site/pages/docs/sidebar.go @@ -0,0 +1,127 @@ +package docs + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +type Section struct { + Title string + Pages []*Page +} + +type Page struct { + Title string + Path string +} + +func DocPath(path string) string { + return "/docs" + path +} + +var sections = []Section{ + { + Title: "Getting Started", + Pages: []*Page{ + {Title: "Introduction", Path: DocPath("/introduction")}, + {Title: "Quick Start", Path: DocPath("/installation")}, + {Title: "Related Projects", Path: DocPath("/related-projects")}, + }, + }, + { + Title: "Core Concepts", + Pages: []*Page{ + {Title: "Pages", Path: DocPath("/core-concepts/pages")}, + {Title: "Partials", Path: DocPath("/core-concepts/partials")}, + {Title: "Components", Path: DocPath("/core-concepts/components")}, + {Title: "Tags and Attributes", Path: DocPath("/core-concepts/tags-and-attributes")}, + {Title: "Raw HTML", Path: DocPath("/core-concepts/raw-html")}, + }, + }, + { + Title: "Control", + Pages: []*Page{ + {Title: "Conditionals", Path: DocPath("/control/if-else")}, + {Title: "Rendering Lists", Path: DocPath("/control/loops")}, + }, + }, + { + Title: "Interactivity", + Pages: []*Page{ + {Title: "Swapping", Path: DocPath("/interactivity/swapping")}, + {Title: "Events", Path: DocPath("/interactivity/events")}, + {Title: "Evaluating Javascript", Path: DocPath("/interactivity/events")}, + {Title: "Eval Commands", Path: DocPath("/interactivity/eval-commands")}, + }, + }, + { + Title: "Performance", + Pages: []*Page{ + {Title: "Caching Globally", Path: DocPath("/performance/caching-globally")}, + {Title: "Caching Per Key", Path: DocPath("/performance/caching-per-key")}, + }, + }, + { + Title: "Pushing Data", + Pages: []*Page{ + {Title: "Server Sent Events", Path: DocPath("/pushing-data/sse")}, + }, + }, + { + Title: "HTMX Extensions", + Pages: []*Page{ + {Title: "Overview", Path: DocPath("/htmx-extensions/overview")}, + {Title: "Trigger Children", Path: DocPath("/htmx-extensions/trigger-children")}, + {Title: "Mutation Error", Path: DocPath("/htmx-extensions/mutation-error")}, + }, + }, + { + Title: "Miscellaneous", + Pages: []*Page{ + {Title: "Tailwind Intellisense", Path: DocPath("/misc/tailwind-intellisense")}, + {Title: "Formatter", Path: DocPath("/misc/formatter")}, + }, + }, + { + Title: "Configuration", + Pages: []*Page{ + {Title: "Htmgo Config", Path: DocPath("/config/htmgo-config")}, + }, + }, +} + +func DocSidebar() *h.Element { + return h.Div( + h.Class("px-3 py-2 pr-6 md:min-h-screen pb-4 mb:pb-0 bg-neutral-50 border-r border-r-slate-300 overflow-y-auto"), + h.Div( + h.Div( + h.Class("mb-3"), + h.A( + h.Href("#quick-start-introduction"), + h.Text("Documentation"), + h.Class("md:mt-4 text-xl text-slate-900 font-bold"), + ), + ), + h.Div( + h.Class("flex flex-col gap-4"), + h.List(sections, func(entry Section, index int) *h.Element { + return h.Div( + h.P( + h.Text(entry.Title), + h.Class("text-slate-800 font-bold"), + ), + h.Div( + h.Class("pl-4 flex flex-col"), + h.List(entry.Pages, func(page *Page, index int) *h.Element { + return h.A( + h.Href(page.Path), + h.Text(page.Title), + h.Class("text-slate-900 hover:text-rose-400"), + ) + }), + ), + ) + }), + ), + ), + ) +} diff --git a/htmgo-site/pages/examples.go b/htmgo-site/pages/examples.go deleted file mode 100644 index 6922abc..0000000 --- a/htmgo-site/pages/examples.go +++ /dev/null @@ -1,126 +0,0 @@ -package pages - -import ( - "github.com/maddalax/htmgo/framework/h" - "htmgo-site/pages/base" -) - -type Example struct { - Title string - Github string - Demo string - Image string - Description string -} - -var examples = []Example{ - { - Title: "Hacker News Clone", - Github: "https://github.com/maddalax/htmgo/tree/master/examples/hackernews", - Description: "A hacker news reader clone built with htmgo", - Demo: "https://hn.htmgo.dev", - Image: "public/hn-example.jpg", - }, - { - Title: "Chat App Example", - Github: "https://github.com/maddalax/htmgo/tree/master/examples/chat", - Description: "A simple chat application built with htmgo using SSE for real-time updates", - Demo: "https://chat-example.htmgo.dev", - Image: "public/chat-example.jpg", - }, - { - Title: "Todo List MVC", - Github: "https://github.com/maddalax/htmgo/tree/master/examples/todo-list", - Demo: "https://todo-example.htmgo.dev", - Image: "public/todo-example.jpg", - }, - { - Title: "htmgo.dev", - Github: "https://github.com/maddalax/htmgo/tree/master/htmgo-site", - Demo: "https://htmgo.dev", - Image: "public/htmgo-site.jpg", - }, - { - Title: "Form With Loading State", - Github: "https://github.com/maddalax/htmgo/blob/master/htmgo-site/pages/form.go", - Demo: "/form", - Description: "A simple form submission example with a loading state", - }, -} - -func ExamplesPage(ctx *h.RequestContext) *h.Page { - return h.NewPage( - base.PageWithNav(ctx, h.Div( - h.Class("flex items-center justify-center"), - h.Div( - h.Class("w-full px-4 flex flex-col prose max-w-[95vw] md:max-w-3xl mt-6"), - h.Div( - h.Class("flex flex-col mb-6 md:mb-0 md:flex-row justify-between items-center"), - h.Div( - h.H1( - h.Class("text-center md:text-left"), - h.Text("htmgo examples"), - ), - h.H3( - h.Class("-mt-4"), - h.TextF("example projects built with htmgo"), - ), - ), - ), - h.Div( - h.Class("border-b border-b-slate-200 h-1"), - h.Div( - h.Class("mt-4"), - ExampleCards(), - ), - ), - )), - ), - ) -} - -func ExampleCards() *h.Element { - return h.Div( - h.Class("prose-h2:my-1 prose-img:my-1 grid grid-cols-1 gap-6 text-center pb-8"), - h.List(examples, func(example Example, index int) *h.Element { - return h.Div( - h.Class("border border-gray-200 shadow-sm rounded-md px-4 pb-4 bg-neutral-100"), // Removed specific width, handled by grid - h.Div( - h.Class("flex flex-col gap-1 mt-4"), - h.H2( - h.Class("text-lg text-center mb-1"), // Reduced margin at the bottom of the title - h.Text(example.Title), - ), - h.If(example.Image != "", h.Div( - h.A( - h.Href(example.Demo), - h.Class("not-prose"), - h.Img( - h.Src(example.Image), - h.Class("w-[75%] rounded-md mx-auto"), - ), - ), // Ensures image is centered within the card - )), - h.If(example.Description != "", h.Div( - h.Pf(example.Description), - )), - h.Div( - h.Div( - h.Class("flex gap-2 justify-center mt-2"), - h.A( - h.Href(example.Github), - h.Class("not-prose p-2 bg-slate-900 text-white rounded-md"), - h.Text("Github"), - ), - h.A( - h.Href(example.Demo), - h.Class("not-prose p-2 bg-slate-900 text-white rounded-md"), - h.Text("Demo"), - ), - ), - ), - ), - ) - }), - ) -} diff --git a/htmgo-site/pages/examples/chat.go b/htmgo-site/pages/examples/chat.go new file mode 100644 index 0000000..a7b02e6 --- /dev/null +++ b/htmgo-site/pages/examples/chat.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func ChatExample(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &ChatSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/click-to-edit.go b/htmgo-site/pages/examples/click-to-edit.go new file mode 100644 index 0000000..0873c9b --- /dev/null +++ b/htmgo-site/pages/examples/click-to-edit.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func ClickToEditExample(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &ClickToEditSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/code.go b/htmgo-site/pages/examples/code.go new file mode 100644 index 0000000..b06f854 --- /dev/null +++ b/htmgo-site/pages/examples/code.go @@ -0,0 +1,74 @@ +package examples + +import ( + "bytes" + "fmt" + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/ui" + "io" + "log/slog" + "net/http" + "os" + "reflect" + "runtime" + "strings" + "time" +) + +func GetGithubPath(path string) string { + path = strings.ReplaceAll(path, "/examples/", "/snippets/") + return fmt.Sprintf("https://github.com/maddalax/htmgo/tree/master/htmgo-site/partials%s.go", path) +} + +func GetGithubRawPath(path string) string { + path = strings.ReplaceAll(path, "/examples/", "/snippets/") + return fmt.Sprintf("https://raw.githubusercontent.com/maddalax/htmgo/master/htmgo-site/partials%s.go", path) +} + +var RenderCodeToStringCached = h.CachedPerKeyT(time.Minute*30, func(snippet *Snippet) (string, h.GetElementFunc) { + return snippet.path, func() *h.Element { + return renderCodeToString(snippet) + } +}) + +func renderCodeToString(snippet *Snippet) *h.Element { + source := "" + // in development, use the local file + if h.IsDevelopment() { + ptr := reflect.ValueOf(snippet.partial).Pointer() + fnInfo := runtime.FuncForPC(ptr) + if fnInfo == nil { + return h.Empty() + } + file, _ := fnInfo.FileLine(ptr) + b, err := os.ReadFile(file) + if err != nil { + return h.Empty() + } + source = string(b) + } else { + url := GetGithubRawPath(snippet.path) + slog.Info("getting snippet source code", slog.String("url", url)) + resp, err := http.Get(url) + if err != nil { + return h.Empty() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return h.Empty() + } + out := bytes.NewBuffer(nil) + _, err = io.Copy(out, resp.Body) + if err != nil { + return h.Empty() + } + source = out.String() + } + + return ui.CodeSnippet(ui.CodeSnippetProps{ + Code: source, + Lang: "go", + CustomStyles: []string{"border-radius: 0.5rem;"}, + WrapLines: true, + }) +} diff --git a/htmgo-site/pages/examples/data.go b/htmgo-site/pages/examples/data.go new file mode 100644 index 0000000..d00c870 --- /dev/null +++ b/htmgo-site/pages/examples/data.go @@ -0,0 +1,25 @@ +package examples + +import "github.com/maddalax/htmgo/framework/h" + +type Snippet struct { + name string + description string + sidebarName string + path string + partial h.PartialFunc + externalRoute string + sourceCodePath string + category string +} + +func SetSnippet(ctx *h.RequestContext, snippet *Snippet) { + ctx.Set("snippet", snippet) +} + +func GetSnippet(ctx *h.RequestContext) *Snippet { + if ctx.Get("snippet") == nil { + return nil + } + return ctx.Get("snippet").(*Snippet) +} diff --git a/htmgo-site/pages/examples/examples.go b/htmgo-site/pages/examples/examples.go new file mode 100644 index 0000000..f979578 --- /dev/null +++ b/htmgo-site/pages/examples/examples.go @@ -0,0 +1,124 @@ +package examples + +import "htmgo-site/partials/snippets" + +var FormWithLoadingStateSnippet = Snippet{ + category: "Forms", + name: "Form", + description: "A simple form submission example with a loading state", + sidebarName: "Form With Loading State", + path: "/examples/form", + partial: snippets.FormExample, +} + +var FormWithBlurValidationSnippet = Snippet{ + category: "Forms", + name: "Form", + description: "A simple form submission example with validation on blur", + sidebarName: "Form With Blur Validation", + path: "/examples/form-with-blur-validation", + partial: snippets.FormWithBlurValidation, +} + +var UserAuthSnippet = Snippet{ + category: "Projects", + name: "User Authentication", + description: "An example showing basic user registration and login with htmgo", + sidebarName: "User Authentication", + path: "/examples/user-auth", + externalRoute: "https://auth-example.htmgo.dev", + sourceCodePath: "https://github.com/maddalax/htmgo/tree/master/examples/simple-auth", +} + +var ChatSnippet = Snippet{ + category: "Projects", + name: "Chat App", + description: "A simple chat application built with htmgo using SSE for real-time updates", + sidebarName: "Chat App Using SSE", + path: "/examples/chat", + externalRoute: "https://chat-example.htmgo.dev", + sourceCodePath: "https://github.com/maddalax/htmgo/tree/master/examples/chat", +} + +var HackerNewsSnippet = Snippet{ + category: "Projects", + name: "HackerNews Clone", + description: "A hacker news reader clone built with htmgo", + sidebarName: "HackerNews Clone", + path: "/examples/hackernews", + externalRoute: "https://hn.htmgo.dev", + sourceCodePath: "https://github.com/maddalax/htmgo/tree/master/examples/hackernews", +} + +var HtmgoSiteSnippet = Snippet{ + category: "Projects", + name: "Htmgo Doc Site", + description: "The htmgo site built with htmgo, recursion am I right?", + sidebarName: "Htmgo Doc Site", + path: "/examples/htmgo-site", + externalRoute: "https://htmgo.dev", + sourceCodePath: "https://github.com/maddalax/htmgo/tree/master/htmgo-site", +} + +var TodoListSnippet = Snippet{ + category: "Projects", + name: "Todo List", + description: "A todo list built with htmgo", + sidebarName: "Todo List", + path: "/examples/todolist", + externalRoute: "https://todo-example.htmgo.dev", + sourceCodePath: "https://github.com/maddalax/htmgo/tree/master/examples/todo-list", +} + +var ClickToEditSnippet = Snippet{ + category: "Forms", + name: "Inline Click To Edit", + description: "List view of items with a click to edit button and persistence", + sidebarName: "Inline Click To Edit", + path: "/examples/click-to-edit", + partial: snippets.ClickToEdit, +} + +var JsSetTextOnClick = Snippet{ + category: "Interactivity (JS)", + name: "Set Element Text On Click", + description: "A simple example of how to use htmgo with javascript", + sidebarName: "Set Text On Click", + path: "/examples/js-set-text-on-click", + partial: snippets.SetTextOnClick, +} + +var JsHideChildrenOnClick = Snippet{ + category: "Interactivity (JS)", + name: "Hide / Show Children On Click", + description: "Use JS to hide and show children elements on click", + sidebarName: "Hide / Show Children", + path: "/examples/js-hide-children-on-click", + partial: snippets.JsHideChildrenOnClick, +} + +var InputComponentSnippet = Snippet{ + category: "Components", + name: "Input", + description: "An example of how you could build a re-usable input component", + sidebarName: "Text Input", + path: "/examples/input-component", + partial: snippets.InputComponent, +} + +var examples = []Snippet{ + FormWithLoadingStateSnippet, + FormWithBlurValidationSnippet, + ClickToEditSnippet, + + JsSetTextOnClick, + JsHideChildrenOnClick, + + UserAuthSnippet, + ChatSnippet, + HackerNewsSnippet, + TodoListSnippet, + HtmgoSiteSnippet, + + InputComponentSnippet, +} diff --git a/htmgo-site/pages/examples/form-with-blur-validation.go b/htmgo-site/pages/examples/form-with-blur-validation.go new file mode 100644 index 0000000..8e1f579 --- /dev/null +++ b/htmgo-site/pages/examples/form-with-blur-validation.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func FormWithBlurValidation(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &FormWithBlurValidationSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/form.go b/htmgo-site/pages/examples/form.go new file mode 100644 index 0000000..63dcc1f --- /dev/null +++ b/htmgo-site/pages/examples/form.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func FormWithLoadingState(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &FormWithLoadingStateSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/hackernews.go b/htmgo-site/pages/examples/hackernews.go new file mode 100644 index 0000000..f89e822 --- /dev/null +++ b/htmgo-site/pages/examples/hackernews.go @@ -0,0 +1,8 @@ +package examples + +import "github.com/maddalax/htmgo/framework/h" + +func HackerNewsExample(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &HackerNewsSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/htmgo-site.go b/htmgo-site/pages/examples/htmgo-site.go new file mode 100644 index 0000000..f11bf91 --- /dev/null +++ b/htmgo-site/pages/examples/htmgo-site.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func HtmgoSiteExample(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &HtmgoSiteSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/index.go b/htmgo-site/pages/examples/index.go new file mode 100644 index 0000000..c826b16 --- /dev/null +++ b/htmgo-site/pages/examples/index.go @@ -0,0 +1,168 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/pages/base" + "htmgo-site/partials" +) + +func Index(ctx *h.RequestContext) *h.Page { + snippet := GetSnippet(ctx) + return base.RootPageWithTitle( + ctx, + "Examples", + h.Div( + h.Class("flex h-full"), + h.Aside( + h.Class("hidden md:block md:min-w-60 text-white overflow-y-auto"), + SnippetSidebar(), + ), + h.Div( + h.Class("flex flex-col flex-1 overflow-hidden"), + partials.NavBar(ctx, partials.NavBarProps{ + Expanded: false, + ShowPreRelease: false, + }), + h.Main( + h.Div( + h.Class("w-full md:hidden bg-neutral-50 overflow-y-auto mb-4 border-b border-b-slate-300"), + SnippetSidebar(), + ), + h.Class("overflow-y-auto justify-center overflow-x-hidden pb-6 items-center w-full"), + h.Div( + h.Class("flex flex-col mx-auto"), + h.Div( + h.Class("flex flex-col justify-center items-center md:mt-6 mx-auto"), + ), + h.IfElseLazy( + snippet != nil, + func() *h.Element { + return snippetView(ctx, snippet) + }, + emptyState, + ), + ), + ), + ), + ), + ) +} + +func viewSourceButton(snippet *Snippet) *h.Element { + return h.Div( + h.Class("flex gap-2 items-center"), + h.A( + h.Fragment( + githubLogo(), + h.If( + snippet.externalRoute != "", + h.Text("View source"), + ), + ), + h.Href( + h.Ternary(snippet.sourceCodePath == "", GetGithubPath(snippet.path), snippet.sourceCodePath), + ), + h.Class("flex gap-2 items-center font-sm text-blue-500 hover:text-blue-400"), + ), + h.If( + snippet.externalRoute == "", + h.H3( + h.Text("Source Code"), + h.Class("text-lg font-bold"), + ), + ), + ) +} + +func snippetView(ctx *h.RequestContext, snippet *Snippet) *h.Element { + return h.Div( + h.Class("flex flex-col mx-auto items-center gap-6 max-w-[90vw] md:max-w-[75vw] xl:max-w-4xl px-8"), + h.Div( + h.Class("flex flex-col gap-1 w-full"), + h.H2( + h.Text(snippet.name), + h.Class("text-2xl font-bold"), + ), + h.If( + snippet.description != "", + h.P( + h.Text(snippet.description), + h.Class("text-slate-900"), + ), + ), + h.If( + snippet.externalRoute != "", + h.Div( + h.Class("mt-3"), + viewSourceButton(snippet), + ), + ), + ), + h.Div( + h.ClassX("", map[string]bool{ + "mb-6 border px-8 py-4 rounded-md shadow-sm border-slate-200 w-full": snippet.externalRoute == "", + }), + h.IfElse( + snippet.externalRoute != "", + h.Div( + h.Class("relative"), + h.IFrame( + snippet.externalRoute, + h.Class("h-full min-h-[800px] w-[50vw] rounded"), + ), + h.A( + h.Class("w-[50vw] rounded absolute top-0 left-0 h-full bg-gray-800 bg-opacity-50 backdrop-blur-[2px] flex items-center justify-center cursor-pointer"), + h.Href( + snippet.externalRoute, + ), + h.Target("_blank"), + h.Span( + h.Text("Click to view"), + h.Class("text-white text-lg font-bold"), + ), + ), + ), + h.Div( + h.IfElseLazy(snippet.partial != nil, func() *h.Element { + return snippet.partial(ctx).Root + }, h.Empty), + ), + ), + ), + h.If( + snippet.externalRoute == "", + h.Div( + h.Class("flex flex-col gap-2 justify-center"), + viewSourceButton(snippet), + RenderCodeToStringCached(snippet), + ), + ), + ) +} + +func emptyState() *h.Element { + return h.Div( + h.Class("flex flex-col gap-2 justify-center items-center mt-8"), + h.Div( + h.Class("flex gap-2 items-center"), + h.H3( + h.Text("Choose an example on the sidebar to view"), + h.Class("text-lg"), + ), + ), + ) +} + +func githubLogo() *h.Element { + return h.Body( + h.Svg( + h.Attribute("xmlns", "http://www.w3.org/2000/svg"), + h.Width(24), + h.Height(24), + h.Attribute("viewBox", "0 0 24 24"), + h.Path( + h.Attribute("d", "M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"), + ), + ), + ) +} diff --git a/htmgo-site/pages/examples/input-component.go b/htmgo-site/pages/examples/input-component.go new file mode 100644 index 0000000..0fc879b --- /dev/null +++ b/htmgo-site/pages/examples/input-component.go @@ -0,0 +1,8 @@ +package examples + +import "github.com/maddalax/htmgo/framework/h" + +func InputComponentExample(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &InputComponentSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/js-hide-children-on-click.go b/htmgo-site/pages/examples/js-hide-children-on-click.go new file mode 100644 index 0000000..27d9b60 --- /dev/null +++ b/htmgo-site/pages/examples/js-hide-children-on-click.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func JsHideChildrenOnClickPage(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &JsHideChildrenOnClick) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/js-set-text-on-click.go b/htmgo-site/pages/examples/js-set-text-on-click.go new file mode 100644 index 0000000..4dc795b --- /dev/null +++ b/htmgo-site/pages/examples/js-set-text-on-click.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func JsSetTextOnClickPage(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &JsSetTextOnClick) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/sidebar.go b/htmgo-site/pages/examples/sidebar.go new file mode 100644 index 0000000..57c6421 --- /dev/null +++ b/htmgo-site/pages/examples/sidebar.go @@ -0,0 +1,48 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/datastructure/orderedmap" + "github.com/maddalax/htmgo/framework/h" +) + +func SnippetSidebar() *h.Element { + + grouped := h.GroupByOrdered(examples, func(example Snippet) string { + return example.category + }) + + return h.Div( + h.Class("px-3 py-2 pr-6 md:min-h-screen pb-4 mb:pb-0 bg-neutral-50 border-r border-r-slate-300 overflow-y-auto"), + h.Div( + h.Div( + h.Class("mb-3"), + h.A( + h.Href("#"), + h.Text("Examples"), + h.Class("md:mt-4 text-xl text-slate-900 font-bold"), + ), + ), + h.Div( + h.Class("flex flex-col gap-2"), + h.List(grouped.Entries(), func(entry orderedmap.Entry[string, []Snippet], index int) *h.Element { + return h.Div( + h.P( + h.Text(entry.Key), + h.Class("text-slate-800 font-bold"), + ), + h.Div( + h.Class("pl-4 flex flex-col"), + h.List(entry.Value, func(entry Snippet, index int) *h.Element { + return h.A( + h.Href(entry.path), + h.Text(entry.sidebarName), + h.Class("text-slate-900 hover:text-rose-400"), + ) + }), + ), + ) + }), + ), + ), + ) +} diff --git a/htmgo-site/pages/examples/todolist.go b/htmgo-site/pages/examples/todolist.go new file mode 100644 index 0000000..19eeb1d --- /dev/null +++ b/htmgo-site/pages/examples/todolist.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func TodoListExample(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &TodoListSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/user-auth.go b/htmgo-site/pages/examples/user-auth.go new file mode 100644 index 0000000..017d814 --- /dev/null +++ b/htmgo-site/pages/examples/user-auth.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func UserAuthExample(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &UserAuthSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/html-to-go.go b/htmgo-site/pages/html-to-go.go index 644d836..4bc91a5 100644 --- a/htmgo-site/pages/html-to-go.go +++ b/htmgo-site/pages/html-to-go.go @@ -7,19 +7,19 @@ import ( ) func HtmlToGoPage(ctx *h.RequestContext) *h.Page { - return h.NewPage( - base.PageWithNav(ctx, + return base.PageWithNav( + ctx, + h.Div( + h.Class("flex flex-col h-screen items-center justify-center w-full pt-6"), + h.H3( + h.Text("Convert raw html to htmgo code"), + h.Class("text-2xl font-bold"), + ), h.Div( - h.Class("flex flex-col h-full items-center justify-center w-full pt-6"), - h.H3( - h.Text("Convert raw html to htmgo code"), - h.Class("text-2xl font-bold"), - ), - h.Div( - h.Class("h-full w-full flex gap-4 p-8"), - partials.HtmlInput(), - partials.GoOutput(""), - ), + h.Class("h-full w-full flex gap-4 p-8"), + partials.HtmlInput(), + partials.HiddenCopyOutput(""), + partials.GoOutput(""), ), ), ) diff --git a/htmgo-site/pages/index.go b/htmgo-site/pages/index.go index 8470795..1a45d65 100644 --- a/htmgo-site/pages/index.go +++ b/htmgo-site/pages/index.go @@ -6,15 +6,19 @@ import ( ) func IndexPage(ctx *h.RequestContext) *h.Page { - return h.NewPage( - base.PageWithNav(ctx, h.Div( + return base.PageWithNav( + ctx, + h.Div( h.Class("flex items-center justify-center"), h.Div( h.Class("w-full px-4 flex flex-col prose md:max-w-3xl mt-6 mx-auto"), h.Div( h.Class("flex flex-col mb-6 md:mb-0 md:flex-row justify-between items-center"), h.Div( - h.H1F("htmgo", h.Class("text-center md:text-left")), + h.H1F( + "htmgo", + h.Class("text-center md:text-left"), + ), h.H3F( "build simple and scalable systems with %s", "go + htmx", @@ -37,7 +41,7 @@ func IndexPage(ctx *h.RequestContext) *h.Page { MarkdownPage(ctx, "md/index.md", ""), ), ), - )), + ), ), ) } diff --git a/htmgo-site/pages/markdown.go b/htmgo-site/pages/markdown.go index 6bdc933..4add435 100644 --- a/htmgo-site/pages/markdown.go +++ b/htmgo-site/pages/markdown.go @@ -16,13 +16,19 @@ func MarkdownPage(ctx *h.RequestContext, path string, id string) *h.Element { ) } -func MarkdownContent(ctx *h.RequestContext, path string, id string) *h.Element { +func MarkdownContent(ctx *h.RequestContext, path string, id string, additionalClasses ...string) *h.Element { embeddedMd := ctx.Get("embeddedMarkdown").(fs.FS) renderer := service.Get[markdown.Renderer](ctx.ServiceLocator()) return h.Div( - h.If(id != "", h.Id(id)), + h.If( + id != "", + h.Id(id), + ), h.Div( - h.Class("w-full flex flex-col prose max-w-md md:max-w-xl lg:max-w-3xl prose-code:text-black prose-p:my-1 prose:p-0 prose-li:m-0 prose-ul:m-0 prose-ol:m-0"), + h.Class( + "w-full flex flex-col prose max-w-[90vw] md:max-w-[65vw] xl:max-w-4xl prose-code:text-black prose-p:my-1 prose:p-0 prose-li:m-0 prose-ul:m-0 prose-ol:m-0", + h.MergeClasses(additionalClasses...), + ), h.UnsafeRaw(renderer.RenderFile(path, embeddedMd)), ), ) diff --git a/htmgo-site/pages/test.go b/htmgo-site/pages/test.go new file mode 100644 index 0000000..446cc32 --- /dev/null +++ b/htmgo-site/pages/test.go @@ -0,0 +1,54 @@ +package pages + +import ( + "fmt" + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/pages/base" +) + +func TestFormatPage(ctx *h.RequestContext) *h.Page { + return base.RootPage( + ctx, + h.Div( + h.P( + h.Class("hello"), + h.Details( + h.Summary( + h.Text("Summary"), + ), + h.Text("Details"), + ), + h.Id("hi"), + ), + ), + ) +} + +func notPage() int { + test := 1 + fmt.Printf("test: %d\n", test) + return test +} + +func TestOtherPage(ctx *h.RequestContext) *h.Page { + return base.RootPage( + ctx, + h.Div( + h.Id("test"), + h.Details( + h.Summary( + h.Text("Summary"), + ), + h.Text("Details"), + ), + h.Class("flex flex-col gap-2 bg-white h-full"), + h.Id("test"), + h.Details( + h.Summary( + h.Text("Summary"), + ), + h.Text("Details"), + ), + ), + ) +} diff --git a/htmgo-site/pages/time.go b/htmgo-site/pages/time.go index d6349dd..b78bd80 100644 --- a/htmgo-site/pages/time.go +++ b/htmgo-site/pages/time.go @@ -7,11 +7,10 @@ import ( ) func CurrentTimePage(ctx *h.RequestContext) *h.Page { - return h.NewPage( - base.RootPage( - ctx, - h.GetPartial( - partials.CurrentTimePartial, - "load, every 1s"), - )) + return base.RootPage( + ctx, + h.GetPartial( + partials.CurrentTimePartial, + "load, every 1s"), + ) } diff --git a/htmgo-site/partials/form.go b/htmgo-site/partials/form.go deleted file mode 100644 index 4934822..0000000 --- a/htmgo-site/partials/form.go +++ /dev/null @@ -1,13 +0,0 @@ -package partials - -import ( - "github.com/maddalax/htmgo/framework/h" - "time" -) - -func SubmitForm(ctx *h.RequestContext) *h.Partial { - time.Sleep(time.Second * 3) - return h.NewPartial( - h.Div(h.Text("Form submitted")), - ) -} diff --git a/htmgo-site/partials/html-to-go.go b/htmgo-site/partials/html-to-go.go index 3669db8..d6e0e7f 100644 --- a/htmgo-site/partials/html-to-go.go +++ b/htmgo-site/partials/html-to-go.go @@ -8,16 +8,23 @@ import ( func ConvertHtmlToGo(ctx *h.RequestContext) *h.Partial { value := ctx.FormValue("html-input") - parsed := htmltogo.Parse([]byte(value)) + parsed := string(htmltogo.Parse([]byte(value))) - formatted := ui.FormatCode(string(parsed), "height: 100%;") + formatted := ui.FormatCode(ui.CodeSnippetProps{ + Code: parsed, + Lang: "go", + CustomStyles: []string{"height: 100%;"}, + }) - return h.SwapPartial(ctx, GoOutput(formatted)) + return h.SwapManyPartial(ctx, + GoOutput(formatted), + HiddenCopyOutput(parsed), + ) } func HtmlInput() *h.Element { return h.Div( - h.Class("h-[90%] w-1/2 min-w-1/2"), + h.Class("h-full w-1/2 min-w-1/2"), h.TextArea( h.Name("html-input"), h.MaxLength(500*1000), @@ -29,13 +36,29 @@ func HtmlInput() *h.Element { ) } +func HiddenCopyOutput(content string) *h.Element { + return h.Div( + h.Class("hidden"), + h.Id("go-output-raw"), + h.UnsafeRaw(content), + ) +} + func GoOutput(content string) *h.Element { return h.Div( - h.Class("h-[90%] w-1/2 min-w-1/2"), + h.Class("h-full w-1/2 min-w-1/2"), h.Id("go-output"), h.Div( - h.Class("h-[90%] w-full rounded border border-slate-200"), - h.UnsafeRaw(content), + h.Class("h-[90%] w-full rounded border border-slate-200 relative"), + h.Div( + h.Class("h-full"), + h.Id("go-output-content"), + h.UnsafeRaw(content), + ), + h.If( + content != "", + ui.AbsoluteCopyButton("#go-output-raw"), + ), ), ) } diff --git a/htmgo-site/partials/navbar.go b/htmgo-site/partials/navbar.go index 913e09f..2e29625 100644 --- a/htmgo-site/partials/navbar.go +++ b/htmgo-site/partials/navbar.go @@ -34,6 +34,31 @@ var CachedStar = h.CachedT(time.Minute*15, func(t *h.RequestContext) *h.Element return Star(t) }) +func Logo() *h.Element { + return h.Svg( + h.Attribute("viewBox", "0 0 370 80.8775381264543"), + h.Class("h-full w-full"), + h.Tag( + "G", + h.Attribute("transform", "matrix(1.276616840702525,0,0,1.276616840702525,-4.447757222875277,-26.431067200135733)"), + h.Attribute("fill", "#111111"), + h.Path( + h.Attribute("xmlns", "http://www.w3.org/2000/svg"), + h.Attribute("fill", "#111111"), + h.Attribute("d", "M48.34863,25.46777c-0.23438,0.48438-0.47461,0.99414-0.72461,1.55859 c-3.42871,7.72266-11.42285,25.09375-13.74707,30.13672c-1.5293-1.76367-3.08398-3.55469-4.55859-5.24609L48.34863,25.46777z M5.83398,68.5127l10.04492-21.2168c0.31445-0.83008,1.5293-3.20117,2.93848-3.20117c0.02832,0,0.05957,0.01172,0.08887,0.01367 c-0.67969,0.76563-1.42188,2.04102-2.00195,4.17578c-1.03125,3.79492-4.48535,16.94922-5.34473,20.22852H5.83398z M13.62695,68.5127 c1.03516-3.94531,4.22461-16.08984,5.20703-19.70313c0.63086-2.32227,1.39551-3.26367,1.83691-3.63477 c0.00684,0.00781,0.01367,0.01172,0.02051,0.01953C23.94629,48.68066,36.72852,63.5127,41.0293,68.5127H13.62695z M43.66699,68.5127 c-1.00293-1.16602-4.45117-5.17773-8.35352-9.68945c1.65039-3.58008,10.4834-22.75195,14.13867-30.98633 c2.32031-5.22852,3.6582-6.17773,4.04297-6.34961c0.82715,0.41797,1.73926,1.29102,2.66992,2.61719 c3.94141,5.61719,23.45703,37.07422,28.00098,44.4082H43.66699z"), + ), + ), + h.Tag( + "G", + h.Attribute("transform", "matrix(4.097970099125154,0,0,4.097970099125154,114.03704346489575,-20.491491909735604)"), + h.Attribute("fill", "#111111"), + h.Path( + h.Attribute("d", "M7.5293 9.766 c2.2461 0 3.5938 1.25 3.5938 3.7598 l0 6.4746 l-2.8223 0 l0 -6.0156 c0 -1.4746 -0.82031 -1.9629 -1.8262 -1.9629 c-1.0449 0 -2.1875 0.51758 -2.207 2.4414 l0 5.5371 l-2.8125 0 l0 -15 l2.8125 0 l0 6.1621 c0.71289 -0.86914 1.8359 -1.3965 3.2617 -1.3965 z M17.568346875 20 c-1.9531 0 -3.0664 -1.1328 -3.0664 -3.1348 l0 -4.7461 l-1.9727 0 l0 -2.1582 l0.63477 0 c1.0645 0 1.6504 -0.41016 1.6504 -1.9141 l0 -1.3281 l2.5391 0 l0 3.2422 l2.0703 0 l0 2.1582 l-2.0703 0 l0 4.4434 c0 0.89844 0.43945 1.2988 1.1621 1.2988 l0.9082 0 l0 2.1387 l-1.8555 0 z M33.496484375 9.766 c2.1484 0 3.5352 1.0938 3.5352 3.1543 l0 7.0801 l-2.8125 0 l0 -6.2793 c0 -1.1816 -0.74219 -1.6992 -1.582 -1.6992 c-1.0059 0 -1.8945 0.57617 -1.8945 2.3145 l0 5.6641 l-2.8418 0 l0 -6.25 c0 -1.2012 -0.72266 -1.7285 -1.6113 -1.7285 c-0.97656 0 -1.8848 0.57617 -1.8848 2.4609 l0 5.5176 l-2.8027 0 l0 -10.039 l2.8027 0 l0 1.1816 c0.66406 -0.88867 1.6797 -1.377 2.9102 -1.377 c1.4551 0 2.5488 0.52734 3.0762 1.5039 c0.70313 -1.0059 1.7773 -1.5039 3.1055 -1.5039 z M46.679646875 9.961 l2.6758 0 l0 9.2871 c0 3.9063 -2.1191 5.4883 -5.3223 5.4883 c-2.8809 0 -4.4434 -1.2109 -5.1758 -3.1152 l2.334 -0.99609 c0.56641 1.2988 1.3867 1.9238 2.7344 1.9238 c1.7773 0 2.6074 -1.1133 2.6074 -3.0957 l0 -1.1719 c-0.58594 0.80078 -1.7383 1.3672 -3.0469 1.3672 c-2.4902 0 -4.5801 -1.9629 -4.5801 -4.9609 c0 -3.0078 2.0996 -4.9219 4.5996 -4.9219 c1.4063 0 2.5586 0.625 3.1055 1.5234 z M44.208946875 17.373 c1.4648 0 2.5977 -1.1914 2.5977 -2.6855 c0 -1.5039 -1.1133 -2.6953 -2.5977 -2.6953 c-1.4746 0 -2.5879 1.1426 -2.5879 2.6953 c0 1.5332 1.1328 2.6855 2.5879 2.6855 z M56.9531125 20.19531 c-3.1934 0 -5.498 -1.9434 -5.498 -5.2246 c0 -3.2617 2.2852 -5.2051 5.498 -5.2051 c3.2324 0 5.5078 1.9434 5.5078 5.2051 c0 3.2813 -2.2852 5.2246 -5.5078 5.2246 z M56.9238125 17.959 c1.6309 0 2.7441 -1.1914 2.7441 -2.9883 s-1.1133 -2.9883 -2.7441 -2.9883 c-1.5723 0 -2.6758 1.1914 -2.6758 2.9883 s1.1035 2.9883 2.6758 2.9883 z"), + ), + ), + ) +} + func Star(ctx *h.RequestContext) *h.Element { type Repo struct { @@ -58,43 +83,89 @@ func Star(ctx *h.RequestContext) *h.Element { h.Class("w-4 h-4 -mt-0.5 mr-0.5 stroke-current text-white"), h.Attribute("xmlns", "http://www.w3.org/2000/svg"), h.Attribute("viewBox", "0 0 24 24"), - h.Attribute("fill", "none"), // No fill - h.Attribute("stroke", "currentColor"), // Apply stroke - h.Attribute("stroke-width", "2"), // Stroke width + h.Attribute("fill", "none"), + h.Attribute("stroke", "currentColor"), + h.Attribute("stroke-width", "2"), h.Path( h.D("M12 17.27l5.18 3.05-1.64-5.68 4.46-3.87-5.88-.5L12 3.5l-2.12 6.77-5.88.5 4.46 3.87-1.64 5.68L12 17.27z"), ), ), h.Text("Star"), ), - h.If(count > 0, h.Div( - h.Class("flex items-center px-3 py-1 bg-black text-white text-sm font-semibold"), - h.Pf("%d", count), - )), + h.If( + count > 0, + h.Div( + h.Class("flex items-center px-3 py-1 bg-black text-white text-sm font-semibold"), + h.Pf("%d", count), + ), + ), + ) +} + +func DiscordSvg() *h.Element { + return h.Svg( + h.Attribute("viewBox", "0 -28.5 256 256"), + h.Attribute("xmlns", "http://www.w3.org/2000/svg"), + h.Attribute("preserveaspectratio", "xMidYMid"), + h.Attribute("fill", "#000000"), + h.Tag( + "g", + h.Id("SVGRepo_bgCarrier"), + h.Attribute("stroke-width", "0"), + ), + h.Tag( + "g", + h.Id("SVGRepo_tracerCarrier"), + h.Attribute("stroke-linecap", "round"), + h.Attribute("stroke-linejoin", "round"), + ), + h.Tag( + "g", + h.Id("SVGRepo_iconCarrier"), + h.Tag( + "g", + h.Path( + h.Attribute("d", "M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z"), + h.Attribute("fill", "#000000"), + h.Attribute("fill-rule", "nonzero"), + ), + ), + ), ) } func NavBar(ctx *h.RequestContext, props NavBarProps) *h.Element { - prelease := h.If(props.ShowPreRelease, h.A( - h.Class("bg-yellow-200 text-yellow-800 text-center p-2 flex items-center justify-center"), - h.Href("https://github.com/maddalax/htmgo/issues"), - h.Attribute("target", "_blank"), - h.Text("htmgo is in alpha release. Please report any issues on GitHub."), - )) - + banner := h.If( + true, + h.A( + h.Href("/discord"), + h.Attribute("target", "_blank"), + h.Class("bg-blue-200 text-slate-700 text-center p-2 flex items-center justify-center gap-2"), + h.Div( + h.Class("w-[30px] h-[30px]"), + DiscordSvg(), + ), + h.P( + h.Text("Join the "), + h.Span( + h.Text("discord server"), + h.Class("underline"), + ), + h.Text(" for support and the latest news."), + ), + ), + ) desktopNav := h.Nav( h.Class("hidden sm:block bg-neutral-100 border border-b-slate-300 p-4 md:p-3 max-h-[100vh - 9rem] overflow-y-auto"), h.Div( h.Class("max-w-[95%] md:max-w-3xl px-4 mx-auto"), h.Div( h.Class("flex justify-between items-center"), - h.Div( - h.Class("flex items-center"), - h.A( - h.Class("text-2xl"), - h.Href("/"), - h.Text("htmgo"), - )), + h.A( + h.Href("/"), + h.Class("mt-1 max-w-[125px]"), + Logo(), + ), h.Div( h.Id("search-container"), ), @@ -118,7 +189,7 @@ func NavBar(ctx *h.RequestContext, props NavBarProps) *h.Element { return h.Div( h.Id("navbar"), - prelease, + banner, MobileNav(ctx, props.Expanded), desktopNav, ) @@ -135,47 +206,57 @@ func MobileNav(ctx *h.RequestContext, expanded bool) *h.Element { h.Div( h.Class("flex items-center"), h.A( - h.Boost(), - h.Class("text-2xl"), h.Href("/"), - h.Text("htmgo"), - )), + h.Class("mt-1 max-w-[125px]"), + Logo(), + ), + ), h.Div( h.Class("flex items-center gap-3"), - h.Div(h.Class("mt-1"), CachedStar(ctx)), + h.Div( + h.Class("mt-1"), + CachedStar(ctx), + ), h.Button( h.Boost(), - h.GetPartialWithQs( ToggleNavbar, - h.NewQs("expanded", h.Ternary(expanded, "false", "true"), "test", "true"), + h.NewQs( + "expanded", + h.Ternary(expanded, "false", "true"), + "test", + "true", + ), "click", ), - h.AttributePairs( - "class", "text-2xl", - "aria-expanded", h.Ternary(expanded, "true", "false"), + "class", + "text-2xl", + "aria-expanded", + h.Ternary(expanded, "true", "false"), ), - h.Class("text-2xl"), h.UnsafeRaw("☰"), ), ), ), ), - h.If(expanded, h.Div( - h.Class("mt-2 ml-2 flex flex-col gap-2"), - h.List(navItems, func(item NavItem, index int) *h.Element { - return h.Div( - h.Class("flex items-center"), - h.A( - h.Boost(), - h.Class(""), - h.Href(item.Url), - h.Text(item.Name), - ), - ) - }), - )), + h.If( + expanded, + h.Div( + h.Class("mt-2 ml-2 flex flex-col gap-2"), + h.List(navItems, func(item NavItem, index int) *h.Element { + return h.Div( + h.Class("flex items-center"), + h.A( + h.Boost(), + h.Class(""), + h.Href(item.Url), + h.Text(item.Name), + ), + ) + }), + ), + ), ) } diff --git a/htmgo-site/partials/sidebar.go b/htmgo-site/partials/sidebar.go deleted file mode 100644 index 8387f48..0000000 --- a/htmgo-site/partials/sidebar.go +++ /dev/null @@ -1,91 +0,0 @@ -package partials - -import ( - "github.com/maddalax/htmgo/framework/h" - "htmgo-site/internal/datastructures" - "htmgo-site/internal/dirwalk" - "strings" -) - -func formatPart(part string) string { - if part[1] == '_' { - part = part[2:] - } - part = strings.ReplaceAll(part, "-", " ") - part = strings.ReplaceAll(part, "_", " ") - part = strings.Title(part) - return part -} - -func CreateAnchor(parts []string) string { - return strings.Join(h.Map(parts, func(part string) string { - return strings.ReplaceAll(strings.ToLower(formatPart(part)), " ", "-") - }), "-") -} - -func partsToName(parts []string) string { - builder := strings.Builder{} - for i, part := range parts { - if i == 0 { - continue - } - part = formatPart(part) - builder.WriteString(part) - builder.WriteString(" ") - } - - return builder.String() -} - -func groupByFirstPart(pages []*dirwalk.Page) *datastructures.OrderedMap[string, []*dirwalk.Page] { - grouped := datastructures.NewOrderedMap[string, []*dirwalk.Page]() - for _, page := range pages { - if len(page.Parts) > 0 { - section := page.Parts[0] - existing, has := grouped.Get(section) - if !has { - existing = []*dirwalk.Page{} - grouped.Set(section, existing) - } - grouped.Set(section, append(existing, page)) - } - } - return grouped -} - -func DocSidebar(pages []*dirwalk.Page) *h.Element { - grouped := groupByFirstPart(pages) - - return h.Div( - h.Class("px-3 py-2 pr-6 min-h-screen bg-neutral-50 border-r border-r-slate-300 overflow-y-auto"), - h.Div( - h.Div( - h.Class("mb-3"), - h.A( - h.Href("#quick-start-introduction"), - h.Text("Documentation"), - h.Class("md:mt-4 text-xl text-slate-900 font-bold"), - ), - ), - h.Div( - h.Class("flex flex-col gap-4"), - h.List(grouped.Entries(), func(entry datastructures.Entry[string, []*dirwalk.Page], index int) *h.Element { - return h.Div( - h.P(h.Text(formatPart(entry.Key)), h.Class("text-slate-800 font-bold")), - h.Div( - h.Class("pl-4 flex flex-col"), - h.List(entry.Value, func(page *dirwalk.Page, index int) *h.Element { - anchor := CreateAnchor(page.Parts) - return h.A( - h.Href("#"+anchor), - h.Text(partsToName(page.Parts)), - h.Class("text-slate-900 hover:text-rose-400"), - ) - }), - ), - ) - }), - ), - ), - ) -} diff --git a/htmgo-site/partials/snippets/click-to-edit.go b/htmgo-site/partials/snippets/click-to-edit.go new file mode 100644 index 0000000..eb9ae44 --- /dev/null +++ b/htmgo-site/partials/snippets/click-to-edit.go @@ -0,0 +1,170 @@ +package snippets + +import ( + "fmt" + "github.com/maddalax/htmgo/framework/h" +) + +// RowClasses defined here for simplicity of the example +var RowClasses = "whitespace-nowrap px-4 py-4 font-medium text-gray-900 text-left" +var ButtonClasses = "inline-block rounded bg-slate-900 px-4 py-2 text-xs font-medium text-white hover:bg-slate-800" +var InputClasses = "-ml-2 max-w-[125px] border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800" + +type Record struct { + Id string + Name string + Birthday string + Role string + Salary string +} + +var records = []Record{ + { + Id: "1", + Name: "John Doe", + Birthday: "24/05/1995", + Role: "htmgo developer", + Salary: "$250,000", + }, + { + Id: "2", + Name: "Jake Smith", + Birthday: "24/05/1995", + Role: "htmx developer", + Salary: "$100,000", + }, +} + +func ClickToEdit(ctx *h.RequestContext) *h.Partial { + return h.NewPartial( + h.Div( + h.Class("flex gap-2 items-center w-full"), + Table(), + ), + ) +} + +// StartEditing is a partial that is called when the user clicks on the edit button, +// it will swap in the form for editing for the given record +func StartEditing(ctx *h.RequestContext) *h.Partial { + id := ctx.QueryParam("id") + + record := h.Find(records, func(record *Record) bool { + return record.Id == id + }) + + if record == nil { + return h.EmptyPartial() + } + + return h.SwapManyPartial( + ctx, + TableRow(record, true), + ) +} + +// SaveEditing is a partial that is called when the user clicks on the save button while editing, +// it will update the record with the new values and swap it back out +// note: in the example, we are just creating a new record in memory instead of updating the existing one, +// normally you would update a persistent record in a database +func SaveEditing(ctx *h.RequestContext) *h.Partial { + id := ctx.QueryParam("id") + + // just for the example, create a new record so it doesn't affect the global original + record := Record{ + Id: id, + Name: ctx.FormValue("name"), + Birthday: ctx.FormValue("birthday"), + Role: ctx.FormValue("role"), + Salary: ctx.FormValue("salary"), + } + + return h.SwapPartial(ctx, TableRow(&record, false)) +} + +func Table() *h.Element { + return h.Div( + h.Class("overflow-x-auto w-full"), + h.Table( + h.Class("divide-y divide-gray-200 bg-white table-fixed"), + h.THead( + h.Tr( + h.Th( + h.Class(RowClasses), + h.Text("Name"), + ), + h.Th( + h.Class(RowClasses), + h.Text("Date of Birth"), + ), + h.Th( + h.Class(RowClasses), + h.Text("Role"), + ), + h.Th( + h.Class(RowClasses), + h.Text("Salary"), + ), + h.Th( + h.Class("px-4 py-2"), + ), + ), + ), + h.TBody( + h.Class("divide-y divide-gray-200"), + h.List(records, func(record Record, index int) *h.Element { + return TableRow(&record, false) + }), + ), + ), + ) +} + +func TableRow(record *Record, editing bool) *h.Element { + recordId := fmt.Sprintf("record-%s", record.Id) + + var Cell = func(name string, value string) *h.Element { + return h.Td( + h.Class(RowClasses, "h-[75px]"), + h.IfElse( + !editing, + h.Pf( + value, + h.Class("w-[125px]"), + ), + h.Input( + "text", + h.Class(InputClasses), + h.Value(value), + h.Name(name), + ), + ), + ) + } + + return h.Tr( + h.If( + editing, + // this is important to make sure the inputs are included in the form submission + h.HxInclude("input"), + ), + h.Id(recordId), + Cell("name", record.Name), + Cell("birthday", record.Birthday), + Cell("role", record.Role), + Cell("salary", record.Salary), + // Edit button + h.Td( + h.Button( + h.Class(ButtonClasses), + h.PostPartialWithQs( + h.Ternary(!editing, StartEditing, SaveEditing), + h.NewQs("id", record.Id), + ), + h.Text( + h.Ternary(!editing, "Edit", "Save"), + ), + ), + ), + ) +} diff --git a/htmgo-site/partials/snippets/form-with-blur-validation.go b/htmgo-site/partials/snippets/form-with-blur-validation.go new file mode 100644 index 0000000..794683a --- /dev/null +++ b/htmgo-site/partials/snippets/form-with-blur-validation.go @@ -0,0 +1,94 @@ +package snippets + +import ( + "github.com/maddalax/htmgo/framework/h" + "github.com/maddalax/htmgo/framework/hx" +) + +func FormWithBlurValidation(ctx *h.RequestContext) *h.Partial { + buttonClasses := "rounded items-center px-3 py-2 bg-slate-800 text-white w-full text-center" + validationPath := h.GetPartialPath( + Validate, + ) + return h.NewPartial( + h.Form( + h.TriggerChildren(), + h.Id("my-form"), + // hx-swap: none is required so the traditional swap doesn't happen, only oob swap + h.NoSwap(), + h.PostPartial(SubmitFormExample), + h.Class("flex flex-col gap-2 max-w-[300px] mx-auto"), + h.LabelFor("name", "Your Name"), + h.Input( + "text", + h.Required(), + h.Class("p-4 rounded-md border border-slate-200"), + h.Name("name"), + h.Placeholder("Name"), + h.Post(validationPath, hx.BlurEvent), + ), + h.Pf( + "type 'htmgo' to see validation errors", + h.Class("text-slate-600 text-sm"), + ), + h.Div( + h.Id("name-error"), + h.Class("text-red-500"), + ), + h.LabelFor("occupation", "Occupation"), + h.Input( + "text", + h.Required(), + h.Class("p-4 rounded-md border border-slate-200"), + h.Name("occupation"), + h.Placeholder("Software Developer"), + ), + h.Button( + h.Type("submit"), + h.Class(buttonClasses), + h.Text("Submit"), + ), + ), + ) +} + +func Validate(ctx *h.RequestContext) *h.Partial { + name := ctx.FormValue("name") + + if name == "htmgo" { + ctx.Response.WriteHeader(400) + return h.SwapPartial( + ctx, + h.Div( + h.Id("name-error"), + h.Text("Name is already taken"), + h.Class("p-4 bg-rose-400 text-white rounded-md"), + ), + ) + } + + return h.EmptyPartial() +} + +func SubmitFormExample(ctx *h.RequestContext) *h.Partial { + + if !ctx.IsHttpPost() { + return h.EmptyPartial() + } + + validate := Validate(ctx) + + // if there is a validation error, swap it in + if !h.IsEmptyPartial(validate) { + return validate + } + + // submit the form + return h.SwapPartial( + ctx, + h.Div( + h.Id("my-form"), + h.Text("Form submitted with name: "+ctx.FormValue("name")), + ), + ) +} diff --git a/htmgo-site/pages/form.go b/htmgo-site/partials/snippets/form.go similarity index 53% rename from htmgo-site/pages/form.go rename to htmgo-site/partials/snippets/form.go index 9415075..6c173ab 100644 --- a/htmgo-site/pages/form.go +++ b/htmgo-site/partials/snippets/form.go @@ -1,34 +1,33 @@ -package pages +package snippets import ( "github.com/maddalax/htmgo/framework/h" "github.com/maddalax/htmgo/framework/hx" "github.com/maddalax/htmgo/framework/js" - "htmgo-site/pages/base" - "htmgo-site/partials" + "time" ) -func Form(ctx *h.RequestContext) *h.Page { - return h.NewPage(base.RootPage(ctx, - h.Div( - h.Class("flex flex-col items-center justify-center p-4 gap-6"), - h.H2F("Form submission with loading state example", h.Class("text-2xl font-bold")), - h.Form( - h.TriggerChildren(), - h.PostPartial(partials.SubmitForm), - h.Class("flex flex-col gap-2"), - h.LabelFor("name", "Your Name"), - h.Input("text", - h.Required(), - h.Class("p-4 rounded-md border border-slate-200"), - h.Name("name"), - h.Placeholder("Name"), - h.OnEvent(hx.KeyDownEvent, js.SubmitFormOnEnter()), +func FormExample(ctx *h.RequestContext) *h.Partial { + return h.NewPartial( + h.Form( + h.TriggerChildren(), + h.PostPartial(SubmitForm), + h.Class("flex flex-col gap-2 max-w-[300px] mx-auto"), + h.LabelFor("name", "Your Name"), + h.Input( + "text", + h.Required(), + h.Class("p-4 rounded-md border border-slate-200"), + h.Name("name"), + h.Placeholder("Name"), + h.OnEvent( + hx.KeyDownEvent, + js.SubmitFormOnEnter(), ), - SubmitButton(), ), + SubmitButton(), ), - )) + ) } func SubmitButton() *h.Element { @@ -60,7 +59,18 @@ func SubmitButton() *h.Element { 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.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"), ) } + +func SubmitForm(ctx *h.RequestContext) *h.Partial { + name := ctx.FormValue("name") + time.Sleep(time.Second * 2) + return h.NewPartial( + h.Div( + h.TextF("Form submitted with name: %s", name), + ), + ) +} diff --git a/htmgo-site/partials/snippets/input-component.go b/htmgo-site/partials/snippets/input-component.go new file mode 100644 index 0000000..1bd780e --- /dev/null +++ b/htmgo-site/partials/snippets/input-component.go @@ -0,0 +1,128 @@ +package snippets + +import ( + "github.com/maddalax/htmgo/framework/h" + "github.com/maddalax/htmgo/framework/hx" +) + +// InputComponent wrapper to make the code snippet work, main code is the Input function +func InputComponent(ctx *h.RequestContext) *h.Partial { + + return h.NewPartial( + h.Div( + h.Class("max-w-sm mx-auto flex flex-col gap-4"), + Input( + InputProps{ + Id: "my-input", + Name: "my-input", + Label: "Input with label", + Type: "text", + Placeholder: "Type something", + Required: true, + }, + h.Attribute("autocomplete", "off"), + h.MaxLength(50), + ), + Input( + InputProps{ + Id: "my-input", + Name: "my-input", + Label: "Input with default value", + Type: "text", + DefaultValue: "Default value", + }, + ), + Input( + InputProps{ + Id: "my-input", + Name: "my-input", + Label: "Input with helper text", + Type: "text", + Placeholder: "Full name", + HelperText: "This should be your full legal name", + }, + ), + ), + ) +} + +type InputProps struct { + Id string + Label string + Name string + Type string + DefaultValue string + Placeholder string + Required bool + ValidationPath string + HelperText string +} + +func Input(props InputProps, children ...h.Ren) *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"), + h.If( + props.Id != "", + h.Id(props.Id), + ), + h.If( + props.Name != "", + h.Name(props.Name), + ), + h.If( + children != nil, + h.Children(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.Class("flex flex-col gap-1"), + h.If( + props.Label != "", + h.Label( + h.Text(props.Label), + ), + ), + input, + h.If( + props.HelperText != "", + h.Div( + h.Class("text-slate-600 text-sm"), + h.Text(props.HelperText), + ), + ), + h.Div( + h.Id(props.Id+"-error"), + h.Class("text-red-500"), + ), + ) + + return wrapped +} diff --git a/htmgo-site/partials/snippets/js-hide-children-on-click.go b/htmgo-site/partials/snippets/js-hide-children-on-click.go new file mode 100644 index 0000000..b704e47 --- /dev/null +++ b/htmgo-site/partials/snippets/js-hide-children-on-click.go @@ -0,0 +1,32 @@ +package snippets + +import ( + "github.com/maddalax/htmgo/framework/h" + "github.com/maddalax/htmgo/framework/js" +) + +func JsHideChildrenOnClick(ctx *h.RequestContext) *h.Partial { + text := h.Pf("- Parent") + return h.NewPartial( + h.Div( + text, + h.Class("cursor-pointer"), + h.Id("js-test"), + h.OnClick( + js.ToggleClassOnChildren("div", "hidden"), + js.EvalCommands( + text, + js.ToggleText("+ Parent", "- Parent"), + ), + ), + h.Div( + h.Class("ml-4"), + h.Text("Child 1"), + ), + h.Div( + h.Class("ml-4"), + h.Text("Child 2"), + ), + ), + ) +} diff --git a/htmgo-site/partials/snippets/js-set-text-on-click.go b/htmgo-site/partials/snippets/js-set-text-on-click.go new file mode 100644 index 0000000..6195104 --- /dev/null +++ b/htmgo-site/partials/snippets/js-set-text-on-click.go @@ -0,0 +1,18 @@ +package snippets + +import ( + "github.com/maddalax/htmgo/framework/h" + "github.com/maddalax/htmgo/framework/js" +) + +func SetTextOnClick(ctx *h.RequestContext) *h.Partial { + return h.NewPartial( + h.Button( + h.Text("Click to set text"), + h.Class("bg-slate-900 text-white py-2 px-4 rounded"), + h.OnClick( + js.SetText("Hello World"), + ), + ), + ) +} diff --git a/htmgo-site/partials/time.go b/htmgo-site/partials/time.go index decda3a..610161d 100644 --- a/htmgo-site/partials/time.go +++ b/htmgo-site/partials/time.go @@ -9,7 +9,12 @@ func CurrentTimePartial(ctx *h.RequestContext) *h.Partial { now := time.Now() return h.NewPartial( h.Div( - h.Pf("The current time is %s", now.Format(time.RFC3339)), + h.Class("flex gap-1 items-center"), + h.Pf("The current time is "), + h.Span( + h.Text(now.Format(time.RFC3339)), + h.Class("font-bold"), + ), ), ) } diff --git a/htmgo-site/ui/copy.go b/htmgo-site/ui/copy.go new file mode 100644 index 0000000..455a76e --- /dev/null +++ b/htmgo-site/ui/copy.go @@ -0,0 +1,33 @@ +package ui + +import ( + "fmt" + "github.com/maddalax/htmgo/framework/h" + "github.com/maddalax/htmgo/framework/js" +) + +func CopyButton(selector string, classes ...string) *h.Element { + classes = append(classes, "flex p-2 bg-slate-800 text-white cursor-pointer items-center") + return h.Div( + h.Class(classes...), + h.Text("Copy"), + h.OnClick( + // language=JavaScript + js.EvalJs(fmt.Sprintf(` + if(!navigator.clipboard) { + return; + } + let text = document.querySelector("%s").innerText; + navigator.clipboard.writeText(text); + self.innerText = "Copied!"; + setTimeout(() => { + self.innerText = "Copy"; + }, 1000); + `, selector)), + ), + ) +} + +func AbsoluteCopyButton(selector string) *h.Element { + return CopyButton(selector, "absolute top-0 right-0 rounded-bl-md") +} diff --git a/htmgo-site/ui/snippet.go b/htmgo-site/ui/snippet.go index 68c9c1c..c5e07f2 100644 --- a/htmgo-site/ui/snippet.go +++ b/htmgo-site/ui/snippet.go @@ -7,19 +7,29 @@ import ( "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" + "github.com/google/uuid" "github.com/maddalax/htmgo/framework/h" + "io" + "net/http" "strings" ) -func FormatCode(code string, customStyles ...string) string { +func FormatCode(props CodeSnippetProps) string { + + if props.SingleLine { + props.CustomStyles = append(props.CustomStyles, "height: 50px; width: 100%;") + } + var buf bytes.Buffer - lexer := lexers.Get("go") + lexer := lexers.Get(props.Lang) style := styles.Get("github") formatter := html.New( + html.WrapLongLines(props.WrapLines), + html.WithLineNumbers(!props.SingleLine && !props.HideLineNumbers), html.WithCustomCSS(map[chroma.TokenType]string{ - chroma.PreWrapper: fmt.Sprintf("padding: 12px; overflow: auto; %s", strings.Join(customStyles, ";")), + chroma.PreWrapper: fmt.Sprintf("border-radius: 0.2rem; line-height: 24px; font-size: 14px; padding: 12px; overflow: auto; background-color: rgb(245, 245, 245) !important; %s", strings.Join(props.CustomStyles, ";")), })) - iterator, err := lexer.Tokenise(nil, code) + iterator, err := lexer.Tokenise(nil, props.Code) if err != nil { return "" } @@ -27,8 +37,95 @@ func FormatCode(code string, customStyles ...string) string { return buf.String() } -func CodeSnippet(code string) *h.Element { +type CodeSnippetProps struct { + Code string + Lang string + CustomStyles []string + HideLineNumbers bool + SingleLine bool + WrapLines bool +} + +func CodeSnippet(props CodeSnippetProps) *h.Element { + id := fmt.Sprintf("code-snippet-%s", uuid.NewString()) + + props.Code = strings.TrimPrefix(props.Code, "\n") + props.Code = strings.TrimSuffix(props.Code, "\n") + + if props.SingleLine { + return h.Div( + h.Class("flex items-center w-full"), + h.Div( + h.UnsafeRaw(props.Code), + h.Class("hidden"), + h.Id(id), + ), + h.UnsafeRaw( + FormatCode(props), + ), + CopyButton("#"+id, "h-[50px] rounded-sm"), + ) + } + return h.Div( - h.UnsafeRaw(FormatCode(code)), + h.Class("relative"), + h.Div( + h.UnsafeRaw(props.Code), + h.Class("hidden"), + h.Id(id), + ), + AbsoluteCopyButton("#"+id), + h.UnsafeRaw( + FormatCode(props), + ), ) } + +func BashCodeSnippet(code string, customStyles ...string) *h.Element { + return CodeSnippet(CodeSnippetProps{ + Code: code, + Lang: "bash", + CustomStyles: customStyles, + }) +} + +func SingleLineBashCodeSnippet(code string, customStyles ...string) *h.Element { + return CodeSnippet(CodeSnippetProps{ + Code: code, + Lang: "bash", + CustomStyles: customStyles, + SingleLine: true, + }) +} + +func GoCodeSnippet(code string, customStyles ...string) *h.Element { + return CodeSnippet(CodeSnippetProps{ + Code: code, + Lang: "go", + CustomStyles: customStyles, + }) +} + +func GoCodeSnippetSingleLine(code string, customStyles ...string) *h.Element { + return CodeSnippet(CodeSnippetProps{ + Code: code, + Lang: "go", + CustomStyles: customStyles, + SingleLine: true, + }) +} + +func CodeSnippetFromUrl(url string, props CodeSnippetProps) *h.Element { + data, err := http.Get(url) + if err != nil { + fmt.Printf("error: %s\n", err.Error()) + return h.Empty() + } + defer data.Body.Close() + b, err := io.ReadAll(data.Body) + if err != nil { + return h.Empty() + } + props.Code = string(b) + return CodeSnippet(props) +} diff --git a/templates/starter/go.mod b/templates/starter/go.mod index 9c33612..a54c796 100644 --- a/templates/starter/go.mod +++ b/templates/starter/go.mod @@ -2,9 +2,10 @@ module starter-template 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 github.com/google/uuid v1.6.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/templates/starter/go.sum b/templates/starter/go.sum index 531d9d1..d23ad5d 100644 --- a/templates/starter/go.sum +++ b/templates/starter/go.sum @@ -4,13 +4,15 @@ 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= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +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= diff --git a/templates/starter/htmgo.yml b/templates/starter/htmgo.yml index d60d2ff..3074bbe 100644 --- a/templates/starter/htmgo.yml +++ b/templates/starter/htmgo.yml @@ -8,3 +8,14 @@ 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"] + +# files or directories to ignore when automatically registering routes for pages +# supports glob patterns through https://github.com/bmatcuk/doublestar +automatic_page_routing_ignore: ["root.go"] + +# files or directories to ignore when automatically registering routes for partials +# supports glob patterns through https://github.com/bmatcuk/doublestar +automatic_partial_routing_ignore: [] + +# url path of where the public assets are located +public_asset_path: "/public" diff --git a/templates/starter/main.go b/templates/starter/main.go index bab347c..a33919b 100644 --- a/templates/starter/main.go +++ b/templates/starter/main.go @@ -1,6 +1,8 @@ package main import ( + "fmt" + "github.com/maddalax/htmgo/framework/config" "github.com/maddalax/htmgo/framework/h" "github.com/maddalax/htmgo/framework/service" "io/fs" @@ -10,6 +12,7 @@ import ( func main() { locator := service.NewLocator() + cfg := config.Get() h.Start(h.AppOpts{ ServiceLocator: locator, @@ -23,7 +26,10 @@ func main() { http.FileServerFS(sub) - app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub))) + // change this in htmgo.yml (public_asset_path) + app.Router.Handle(fmt.Sprintf("%s/*", cfg.PublicAssetPath), + http.StripPrefix(cfg.PublicAssetPath, http.FileServerFS(sub))) + __htmgo.Register(app.Router) }, }) diff --git a/templates/starter/pages/index.go b/templates/starter/pages/index.go index ac1c004..7c81c8a 100644 --- a/templates/starter/pages/index.go +++ b/templates/starter/pages/index.go @@ -6,15 +6,17 @@ import ( ) func IndexPage(ctx *h.RequestContext) *h.Page { - return h.NewPage( - RootPage( + return RootPage( + h.Div( + h.Class("flex flex-col gap-4 items-center pt-24 min-h-screen bg-neutral-100"), + h.H3( + h.Id("intro-text"), + h.Text("hello htmgo"), + h.Class("text-5xl"), + ), h.Div( - h.Class("flex flex-col gap-4 items-center pt-24 min-h-screen bg-neutral-100"), - h.H3(h.Id("intro-text"), h.Text("hello htmgo"), h.Class("text-5xl")), - h.Div( - h.Class("mt-3"), - partials.CounterForm(0), - ), + h.Class("mt-3"), + partials.CounterForm(0), ), ), ) diff --git a/templates/starter/pages/root.go b/templates/starter/pages/root.go index bacdd61..e6e5a0f 100644 --- a/templates/starter/pages/root.go +++ b/templates/starter/pages/root.go @@ -2,30 +2,43 @@ package pages import ( "github.com/maddalax/htmgo/framework/h" + "starter-template/__htmgo/assets" ) -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...), +func RootPage(children ...h.Ren) *h.Page { + title := "htmgo template" + description := "an example of the htmgo template" + author := "htmgo" + url := "https://htmgo.dev" + + return h.NewPage( + h.Html( + h.HxExtensions( + h.BaseExtensions(), + ), + h.Head( + h.Title( + h.Text(title), + ), + h.Meta("viewport", "width=device-width, initial-scale=1"), + h.Link(assets.FaviconIco, "icon"), + h.Link(assets.AppleTouchIconPng, "apple-touch-icon"), + h.Meta("title", title), + h.Meta("charset", "utf-8"), + h.Meta("author", author), + h.Meta("description", description), + h.Meta("og:title", title), + h.Meta("og:url", url), + h.Link("canonical", url), + h.Meta("og:description", description), + h.Link(assets.MainCss, "stylesheet"), + h.Script(assets.HtmgoJs), + ), + h.Body( + h.Div( + h.Class("flex flex-col gap-2 bg-white h-full"), + h.Fragment(children...), + ), ), ), ) diff --git a/templates/starter/partials/index.go b/templates/starter/partials/index.go index f5b47e8..bdedba9 100644 --- a/templates/starter/partials/index.go +++ b/templates/starter/partials/index.go @@ -26,7 +26,8 @@ func CounterForm(count int) *h.Element { h.Class("flex flex-col gap-3 items-center"), h.Id("counter-form"), h.PostPartial(CounterPartial), - h.Input("text", + h.Input( + "text", h.Class("hidden"), h.Value(count), h.Name("count"), diff --git a/tools/html-to-htmgo/go.mod b/tools/html-to-htmgo/go.mod index 6320b14..e60bec4 100644 --- a/tools/html-to-htmgo/go.mod +++ b/tools/html-to-htmgo/go.mod @@ -3,6 +3,14 @@ module github.com/maddalax/htmgo/tools/html-to-htmgo go 1.23.0 require ( + github.com/stretchr/testify v1.9.0 golang.org/x/net v0.30.0 golang.org/x/text v0.19.0 + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tools/html-to-htmgo/go.sum b/tools/html-to-htmgo/go.sum index 613920c..ef925bc 100644 --- a/tools/html-to-htmgo/go.sum +++ b/tools/html-to-htmgo/go.sum @@ -1,8 +1,16 @@ -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +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/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/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 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.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +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= diff --git a/tools/html-to-htmgo/htmltogo/indent.go b/tools/html-to-htmgo/htmltogo/indent.go new file mode 100644 index 0000000..da7146c --- /dev/null +++ b/tools/html-to-htmgo/htmltogo/indent.go @@ -0,0 +1,148 @@ +package htmltogo + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/printer" + "go/token" + "golang.org/x/tools/go/ast/astutil" + "slices" + "strings" +) + +func Indent(input string) string { + fset := token.NewFileSet() + // Parse the code string into an AST + f, err := parser.ParseFile(fset, "", input, parser.ParseComments) + + if err != nil { + return input + } + + htmgoComponentTypes := []string{ + "h.Element", + "h.Page", + "h.Partial", + "h.Ren", + } + + for _, decl := range f.Decls { + switch c := decl.(type) { + case *ast.FuncDecl: + + if c.Type.Results == nil || len(c.Type.Results.List) == 0 { + continue + } + + returnType := c.Type.Results.List[0].Type + + isHtmgoComponent := false + if v, ok := returnType.(*ast.StarExpr); ok { + if x, ok := v.X.(*ast.SelectorExpr); ok { + name := x.X.(*ast.Ident).Name + str := name + "." + x.Sel.Name + isHtmgoComponent = slices.Contains(htmgoComponentTypes, str) + } + } + + // support non-pointer return types + if v, ok := returnType.(*ast.SelectorExpr); ok { + if x, ok := v.X.(*ast.Ident); ok { + name := x.Name + str := name + "." + v.Sel.Name + isHtmgoComponent = slices.Contains(htmgoComponentTypes, str) + } + } + + if !isHtmgoComponent { + continue + } + + var isHTag = func(n ast.Expr) bool { + switch argc := n.(type) { + // If the first argument is another node, add an indent + case *ast.CallExpr: + if v, ok := argc.Fun.(*ast.SelectorExpr); ok { + if v2, ok := v.X.(*ast.Ident); ok { + if v2.Name == "h" || v2.Name == "js" { + return true + } + } + } + } + return false + } + + var indent = func(children []ast.Expr) []ast.Expr { + children = append(children, ast.NewIdent("INDENTME")) + return children + } + + astutil.Apply(c.Body, nil, func(cursor *astutil.Cursor) bool { + switch n := cursor.Node().(type) { + case *ast.CallExpr: + newChildren := make([]ast.Expr, 0) + + hasAnyHElements := false + + for _, arg := range n.Args { + if isHTag(arg) { + hasAnyHElements = true + break + } + } + + for i, arg := range n.Args { + + if len(n.Args) == 1 && isHTag(arg) { + newChildren = indent(newChildren) + newChildren = append(newChildren, arg) + newChildren = indent(newChildren) + continue + } + + if !hasAnyHElements { + newChildren = append(newChildren, arg) + continue + } + + if len(n.Args) > 1 { + if i == 0 { + newChildren = indent(newChildren) + } + } + newChildren = append(newChildren, arg) + if len(n.Args) > 1 { + newChildren = indent(newChildren) + } + } + n.Args = newChildren + return true + } + return true + }) + } + } + + // Convert the AST node to a string + var buf bytes.Buffer + if err := printer.Fprint(&buf, fset, f); err != nil { + fmt.Println("Error printing AST:", err) + return input + } + + // Output the formatted code + indented := strings.ReplaceAll(buf.String(), "INDENTME,", "\n\t\t") + indented = strings.ReplaceAll(indented, ", INDENTME", ", \n\t\t") + + formatted, err := format.Source([]byte(indented)) + + if err != nil { + return input + } + + return string(formatted) +} diff --git a/tools/html-to-htmgo/htmltogo/indent_test.go b/tools/html-to-htmgo/htmltogo/indent_test.go new file mode 100644 index 0000000..b29679f --- /dev/null +++ b/tools/html-to-htmgo/htmltogo/indent_test.go @@ -0,0 +1,38 @@ +package htmltogo + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestIdentHRen(t *testing.T) { + input := ` + package main + import ( + "github.com/maddalax/htmgo/framework/h" + ) + func Button(props ButtonProps) h.Ren { + return h.Div( + h.Div(h.Div(),h.P(),h.P(), + ), + ) + } + ` + indented := Indent(input) + assert.Equal(t, `package main + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func Button(props ButtonProps) h.Ren { + return h.Div( + h.Div( + h.Div(), + h.P(), + h.P(), + ), + ) +} +`, indented) +} diff --git a/tools/html-to-htmgo/htmltogo/main.go b/tools/html-to-htmgo/htmltogo/main.go index ce382c1..d2806fd 100644 --- a/tools/html-to-htmgo/htmltogo/main.go +++ b/tools/html-to-htmgo/htmltogo/main.go @@ -18,5 +18,5 @@ func Parse(input []byte) []byte { return nil } - return []byte(formatter.Format(parsed)) + return []byte(Indent(formatter.Format(parsed))) } diff --git a/tools/html-to-htmgo/internal/adapters/services/formatter/formatter.go b/tools/html-to-htmgo/internal/adapters/services/formatter/formatter.go index 295a0af..6bfb3bd 100644 --- a/tools/html-to-htmgo/internal/adapters/services/formatter/formatter.go +++ b/tools/html-to-htmgo/internal/adapters/services/formatter/formatter.go @@ -20,7 +20,6 @@ func MyComponent() *h.Element { if err != nil { return string(b) } - return string(dist) } diff --git a/tools/html-to-htmgo/internal/adapters/services/parser/parser_test.go b/tools/html-to-htmgo/internal/adapters/services/parser/parser_test.go index 5d2de8d..2554172 100644 --- a/tools/html-to-htmgo/internal/adapters/services/parser/parser_test.go +++ b/tools/html-to-htmgo/internal/adapters/services/parser/parser_test.go @@ -22,7 +22,7 @@ func FuzzFromBytes(f *testing.F) { if err != nil { return } - if err != nil && !isExpectedError(err) { + if !isExpectedError(err) { t.Errorf("Unexpected error: %v", err) } }) diff --git a/tools/html-to-htmgo/internal/domain/node.go b/tools/html-to-htmgo/internal/domain/node.go index a90e52e..1c1c079 100644 --- a/tools/html-to-htmgo/internal/domain/node.go +++ b/tools/html-to-htmgo/internal/domain/node.go @@ -30,9 +30,6 @@ func (n *CustomNode) SetType(in string) { n.Type = "h.TBody" case "id": n.Type = "h.Id" - case "path": - n.Type = "path" - n.customType = true case "circle": n.Type = "circle" n.customType = true diff --git a/tools/update-htmgo-dep.go b/tools/update-htmgo-dep.go index bd96b80..2737f0d 100644 --- a/tools/update-htmgo-dep.go +++ b/tools/update-htmgo-dep.go @@ -13,6 +13,13 @@ import ( ) const frameworkRepo = "github.com/maddalax/htmgo/framework" +const htmlToHtmgoRepo = "github.com/maddalax/htmgo/tools/html-to-htmgo" + +var depsToUpdate = []string{ + frameworkRepo, + htmlToHtmgoRepo, +} + const githubAPIURL = "https://api.github.com/repos/maddalax/htmgo/commits" // Commit represents the structure of a commit object returned by the GitHub API. @@ -52,17 +59,14 @@ func main() { // Check if the directory contains a go.mod file. if info.IsDir() && fileExists(filepath.Join(path, "go.mod")) { - // Check if the go.mod contains 'github.com/maddalax/htmgo/framework'. - if containsFrameworkDependency(filepath.Join(path, "go.mod")) { - wg.Add(1) - go func() { - defer wg.Done() - // Run go get github.com/maddalax/htmgo/framework@. - fmt.Printf("Running 'go get' with latest commit hash in %s\n", path) - RunCommand(path, "go", "get", fmt.Sprintf("%s@%s", frameworkRepo, latestCommitHash)) - RunCommand(path, "go", "mod", "tidy") - }() - } + goModPath := filepath.Join(path, "go.mod") + wg.Add(1) + go func() { + defer wg.Done() + for _, s := range depsToUpdate { + updateDepToLatestVersion(s, goModPath, latestCommitHash) + } + }() } return nil @@ -82,8 +86,18 @@ func fileExists(path string) bool { return !os.IsNotExist(err) } -// containsFrameworkDependency checks if 'github.com/maddalax/htmgo/framework' is in the go.mod file. -func containsFrameworkDependency(goModPath string) bool { +func updateDepToLatestVersion(dep string, goModPath string, latestCommitHash string) { + if containsDep(dep, goModPath) { + dir := filepath.Dir(goModPath) + // Run go get github.com/maddalax/htmgo/framework@. + fmt.Printf("Running 'go get' with latest commit hash in %s\n", dep) + RunCommand(dir, "go", "get", fmt.Sprintf("%s@%s", dep, latestCommitHash)) + RunCommand(dir, "go", "mod", "tidy") + } +} + +// containsDep checks if 'github.com/maddalax/htmgo/framework' is in the go.mod file. +func containsDep(dep string, goModPath string) bool { file, err := os.Open(goModPath) if err != nil { fmt.Println("Error opening go.mod file:", err) @@ -93,7 +107,7 @@ func containsFrameworkDependency(goModPath string) bool { scanner := bufio.NewScanner(file) for scanner.Scan() { - if strings.Contains(scanner.Text(), frameworkRepo) { + if strings.Contains(scanner.Text(), dep) { return true } }