Compare commits

..

No commits in common. "master" and "v0.3.7" have entirely different histories.

282 changed files with 2344 additions and 12523 deletions

View file

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

View file

@ -1,48 +0,0 @@
name: Build and Deploy ws-test
on:
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch:
push:
branches:
- ws-testing
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/ws-example && docker build -t ghcr.io/${{ github.repository_owner }}/ws-example:${{ steps.vars.outputs.short_sha }} .
- name: Tag as latest Docker image
run: |
docker tag ghcr.io/${{ github.repository_owner }}/ws-example:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/ws-example: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 }}/ws-example:latest

View file

@ -1,33 +0,0 @@
name: CLI Tests
on:
push:
branches:
- master
pull_request:
branches:
- '**' # Runs on any pull request to any branch
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23' # Specify the Go version you need
- name: Install dependencies
run: cd ./cli/htmgo && go mod download
- name: Run Go tests
run: cd ./cli/htmgo/tasks/astgen && go test ./... -coverprofile=coverage.txt
- name: Upload results to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -3,7 +3,7 @@ name: Framework Tests
on: on:
push: push:
branches: branches:
- master - '**' # Runs on any branch push
pull_request: pull_request:
branches: branches:
- '**' # Runs on any pull request to any branch - '**' # Runs on any pull request to any branch
@ -25,9 +25,4 @@ jobs:
run: cd ./framework && go mod download run: cd ./framework && go mod download
- name: Run Go tests - name: Run Go tests
run: cd ./framework && go test ./... -coverprofile=coverage.txt run: cd ./framework && go test ./...
- name: Upload results to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}

View file

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

View file

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

View file

@ -3,25 +3,13 @@ module github.com/maddalax/htmgo/cli/htmgo
go 1.23.0 go 1.23.0
require ( require (
github.com/dave/jennifer v1.7.1
github.com/fsnotify/fsnotify v1.7.0 github.com/fsnotify/fsnotify v1.7.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20250703190716-06f01b3d7c1b
github.com/stretchr/testify v1.9.0
golang.org/x/mod v0.21.0 golang.org/x/mod v0.21.0
golang.org/x/sys v0.26.0 golang.org/x/net v0.29.0
golang.org/x/sys v0.25.0
golang.org/x/tools v0.25.0 golang.org/x/tools v0.25.0
) )
require ( require github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)
require (
github.com/bmatcuk/doublestar/v4 v4.7.1
github.com/go-chi/chi/v5 v5.1.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

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

View file

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

View file

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

View file

@ -2,21 +2,15 @@ package astgen
import ( import (
"fmt" "fmt"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"go/ast" "go/ast"
"go/parser" "go/parser"
"go/token" "go/token"
"io/fs" "golang.org/x/mod/modfile"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"unicode"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/framework/h"
"golang.org/x/mod/modfile"
) )
type Page struct { type Page struct {
@ -30,7 +24,6 @@ type Partial struct {
FuncName string FuncName string
Package string Package string
Import string Import string
Path string
} }
const GeneratedDirName = "__htmgo" const GeneratedDirName = "__htmgo"
@ -41,36 +34,6 @@ const ModuleName = "github.com/maddalax/htmgo/framework/h"
var PackageName = fmt.Sprintf("package %s", GeneratedDirName) var PackageName = fmt.Sprintf("package %s", GeneratedDirName)
var GeneratedFileLine = fmt.Sprintf("// Package %s THIS FILE IS GENERATED. DO NOT EDIT.", GeneratedDirName) var GeneratedFileLine = fmt.Sprintf("// Package %s THIS FILE IS GENERATED. DO NOT EDIT.", GeneratedDirName)
func toPascaleCase(input string) string {
words := strings.Split(input, "_")
for i := range words {
words[i] = strings.Title(strings.ToLower(words[i]))
}
return strings.Join(words, "")
}
func isValidGoVariableName(name string) bool {
// Variable name must not be empty
if name == "" {
return false
}
// First character must be a letter or underscore
if !unicode.IsLetter(rune(name[0])) && name[0] != '_' {
return false
}
// Remaining characters must be letters, digits, or underscores
for _, char := range name[1:] {
if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' {
return false
}
}
return true
}
func normalizePath(path string) string {
return strings.ReplaceAll(path, `\`, "/")
}
func sliceCommonPrefix(dir1, dir2 string) string { func sliceCommonPrefix(dir1, dir2 string) string {
// Use filepath.Clean to normalize the paths // Use filepath.Clean to normalize the paths
dir1 = filepath.Clean(dir1) dir1 = filepath.Clean(dir1)
@ -96,35 +59,9 @@ func sliceCommonPrefix(dir1, dir2 string) string {
// Return the longer one // Return the longer one
if len(slicedDir1) > len(slicedDir2) { if len(slicedDir1) > len(slicedDir2) {
return normalizePath(slicedDir1) return slicedDir1
} }
return normalizePath(slicedDir2) return slicedDir2
}
func hasOnlyReqContextParam(funcType *ast.FuncType) bool {
if len(funcType.Params.List) != 1 {
return false
}
if funcType.Params.List[0].Names == nil {
return false
}
if len(funcType.Params.List[0].Names) != 1 {
return false
}
t := funcType.Params.List[0].Type
name, ok := t.(*ast.StarExpr)
if !ok {
return false
}
selectorExpr, ok := name.X.(*ast.SelectorExpr)
if !ok {
return false
}
ident, ok := selectorExpr.X.(*ast.Ident)
if !ok {
return false
}
return ident.Name == "h" && selectorExpr.Sel.Name == "RequestContext"
} }
func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial) bool) ([]Partial, error) { func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial) bool) ([]Partial, error) {
@ -163,11 +100,10 @@ func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok { if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
// Check if the package name is 'h' and type is 'Partial'. // Check if the package name is 'h' and type is 'Partial'.
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" { if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
if selectorExpr.Sel.Name == "Partial" && hasOnlyReqContextParam(funcDecl.Type) { if selectorExpr.Sel.Name == "Partial" {
p := Partial{ p := Partial{
Package: node.Name.Name, Package: node.Name.Name,
Path: normalizePath(sliceCommonPrefix(cwd, path)), Import: sliceCommonPrefix(cwd, strings.ReplaceAll(filepath.Dir(path), `\`, `/`)),
Import: sliceCommonPrefix(cwd, normalizePath(filepath.Dir(path))),
FuncName: funcDecl.Name.Name, FuncName: funcDecl.Name.Name,
} }
if predicate(p) { if predicate(p) {
@ -230,11 +166,11 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok { if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
// Check if the package name is 'h' and type is 'Partial'. // Check if the package name is 'h' and type is 'Partial'.
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" { if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
if selectorExpr.Sel.Name == "Page" && hasOnlyReqContextParam(funcDecl.Type) { if selectorExpr.Sel.Name == "Page" {
pages = append(pages, Page{ pages = append(pages, Page{
Package: node.Name.Name, Package: node.Name.Name,
Import: normalizePath(filepath.Dir(path)), Import: strings.ReplaceAll(filepath.Dir(path), `\`, `/`),
Path: normalizePath(path), Path: path,
FuncName: funcDecl.Name.Name, FuncName: funcDecl.Name.Name,
}) })
break break
@ -260,51 +196,70 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
} }
func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) { func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
moduleName := GetModuleName() fName := "GetPartialFromContext"
var routerHandlerMethod = func(path string, caller string) string { body := `
return fmt.Sprintf(` path := r.URL.Path
router.Handle("%s", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { `
if len(partials) == 0 {
body = ""
}
moduleName := GetModuleName()
for _, f := range partials {
if f.FuncName == fName {
continue
}
caller := fmt.Sprintf("%s.%s", f.Package, f.FuncName)
path := fmt.Sprintf("/%s/%s.%s", moduleName, f.Import, f.FuncName)
body += fmt.Sprintf(`
if path == "%s" || path == "%s" {
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext) cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
partial := %s(cc) return %s(cc)
}
`, f.FuncName, path, caller)
}
body += "return nil"
f := Function{
Name: fName,
Parameters: []NameType{
{Name: "r", Type: "*http.Request"},
},
Return: []ReturnType{
{Type: "*h.Partial"},
},
Body: body,
}
builder.Append(builder.BuildFunction(f))
registerFunction := fmt.Sprintf(`
func RegisterPartials(router *chi.Mux) {
router.Handle("/%s/partials*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
partial := GetPartialFromContext(r)
if partial == nil { if partial == nil {
w.WriteHeader(404) w.WriteHeader(404)
return return
} }
h.PartialView(w, partial) h.PartialView(w, partial)
}))`, path, caller) }))
} }
`, moduleName)
handlerMethods := make([]string, 0)
for _, f := range partials {
caller := fmt.Sprintf("%s.%s", f.Package, f.FuncName)
path := fmt.Sprintf("/%s/%s.%s", moduleName, f.Import, f.FuncName)
handlerMethods = append(handlerMethods, routerHandlerMethod(path, caller))
}
registerFunction := fmt.Sprintf(`
func RegisterPartials(router *chi.Mux) {
%s
}
`, strings.Join(handlerMethods, "\n"))
builder.AppendLine(registerFunction) builder.AppendLine(registerFunction)
} }
func writePartialsFile() { func writePartialsFile() {
config := dirutil.GetConfig()
cwd := process.GetWorkingDir() cwd := process.GetWorkingDir()
partialPath := filepath.Join(cwd) partialPath := filepath.Join(cwd, "partials")
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool { partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
return partial.FuncName != "GetPartialFromContext" return partial.FuncName != "GetPartialFromContext"
}) })
partials = h.Filter(partials, func(partial Partial) bool {
return !dirutil.IsGlobExclude(partial.Path, config.AutomaticPartialRoutingIgnore)
})
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
return return
@ -313,12 +268,9 @@ func writePartialsFile() {
builder := NewCodeBuilder(nil) builder := NewCodeBuilder(nil)
builder.AppendLine(GeneratedFileLine) builder.AppendLine(GeneratedFileLine)
builder.AppendLine(PackageName) builder.AppendLine(PackageName)
builder.AddImport(ChiModuleName)
if len(partials) > 0 {
builder.AddImport(ModuleName) builder.AddImport(ModuleName)
builder.AddImport(HttpModuleName) builder.AddImport(HttpModuleName)
} builder.AddImport(ChiModuleName)
moduleName := GetModuleName() moduleName := GetModuleName()
for _, partial := range partials { for _, partial := range partials {
@ -365,7 +317,6 @@ func formatRoute(path string) string {
} }
func writePagesFile() { func writePagesFile() {
config := dirutil.GetConfig()
builder := NewCodeBuilder(nil) builder := NewCodeBuilder(nil)
builder.AppendLine(GeneratedFileLine) builder.AppendLine(GeneratedFileLine)
@ -375,10 +326,6 @@ func writePagesFile() {
pages, _ := findPublicFuncsReturningHPage("pages") pages, _ := findPublicFuncsReturningHPage("pages")
pages = h.Filter(pages, func(page Page) bool {
return !dirutil.IsGlobExclude(page.Path, config.AutomaticPageRoutingIgnore)
})
if len(pages) > 0 { if len(pages) > 0 {
builder.AddImport(ModuleName) builder.AddImport(ModuleName)
} }
@ -424,96 +371,9 @@ 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 HasModuleFile(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
func CheckPagesDirectory(path string) error {
pagesPath := filepath.Join(path, "pages")
_, err := os.Stat(pagesPath)
if err != nil {
return fmt.Errorf("The directory pages does not exist.")
}
return nil
}
func GetModuleName() string { func GetModuleName() string {
wd := process.GetWorkingDir() wd := process.GetWorkingDir()
modPath := filepath.Join(wd, "go.mod") modPath := filepath.Join(wd, "go.mod")
if HasModuleFile(modPath) == false {
fmt.Fprintf(os.Stderr, "Module not found: go.mod file does not exist.")
return ""
}
checkDir := CheckPagesDirectory(wd)
if checkDir != nil {
fmt.Fprintf(os.Stderr, checkDir.Error())
return ""
}
goModBytes, err := os.ReadFile(modPath) goModBytes, err := os.ReadFile(modPath)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error reading go.mod: %v\n", err) fmt.Fprintf(os.Stderr, "error reading go.mod: %v\n", err)
@ -532,7 +392,6 @@ func GenAst(flags ...process.RunFlag) error {
} }
writePartialsFile() writePartialsFile()
writePagesFile() writePagesFile()
writeAssetsFile()
WriteFile("__htmgo/setup-generated.go", func(content *ast.File) string { WriteFile("__htmgo/setup-generated.go", func(content *ast.File) string {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +0,0 @@
module astgen-project-sample
go 1.23.0
require github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
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
)

View file

@ -1,18 +0,0 @@
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.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/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=

View file

@ -1,21 +0,0 @@
# 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: []
# url path of where the public assets are located
public_asset_path: "/public"

View file

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

View file

@ -1,36 +0,0 @@
package main
import (
"astgen-project-sample/__htmgo"
"fmt"
"github.com/maddalax/htmgo/framework/config"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
)
func main() {
locator := service.NewLocator()
cfg := config.Get()
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)
// 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)
},
})
}

View file

@ -1,30 +0,0 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func IndexPage(ctx *h.RequestContext) *h.Page {
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("mt-3"),
),
h.Div(),
),
)
}
func TestPartial(ctx *h.RequestContext) *h.Partial {
return h.NewPartial(
h.Div(
h.Text("Hello World"),
),
)
}

View file

@ -1,40 +0,0 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
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.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.Body(
h.Div(
h.Class("flex flex-col gap-2 bg-white h-full"),
h.Fragment(children...),
),
),
),
)
}

View file

@ -1,18 +0,0 @@
package partials
import "github.com/maddalax/htmgo/framework/h"
func CountersPartial(ctx *h.RequestContext) *h.Partial {
return h.NewPartial(
h.Div(
h.Text("my counter"),
),
)
}
func SwapFormError(ctx *h.RequestContext, error string) *h.Partial {
return h.SwapPartial(
ctx,
h.Div(),
)
}

View file

@ -1,66 +0,0 @@
package astgen
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/stretchr/testify/assert"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestAstGen(t *testing.T) {
t.Parallel()
workingDir, err := filepath.Abs("./project-sample")
assert.NoError(t, err)
process.SetWorkingDir(workingDir)
assert.NoError(t, os.Chdir(workingDir))
err = dirutil.DeleteDir(filepath.Join(process.GetWorkingDir(), "__htmgo"))
assert.NoError(t, err)
err = process.Run(process.NewRawCommand("", "go build ."))
assert.Error(t, err)
err = GenAst()
assert.NoError(t, err)
go func() {
// project was buildable after astgen, confirmed working
err = process.Run(process.NewRawCommand("server", "go run ."))
assert.NoError(t, err)
}()
time.Sleep(time.Second * 1)
urls := []string{
"/astgen-project-sample/partials.CountersPartial",
"/",
"/astgen-project-sample/pages.TestPartial",
}
defer func() {
serverProcess := process.GetProcessByName("server")
assert.NotNil(t, serverProcess)
process.KillProcess(*serverProcess)
}()
wg := sync.WaitGroup{}
for _, url := range urls {
wg.Add(1)
go func() {
defer wg.Done()
// ensure we can get a 200 response on the partials
resp, e := http.Get(fmt.Sprintf("http://localhost:3000%s", url))
assert.NoError(t, e)
assert.Equal(t, http.StatusOK, resp.StatusCode, fmt.Sprintf("%s was not a 200 response", url))
}()
}
wg.Wait()
}

View file

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

View file

@ -78,7 +78,7 @@ func downloadTailwindCli() {
log.Fatal(fmt.Sprintf("Unsupported OS/ARCH: %s/%s", os, arch)) log.Fatal(fmt.Sprintf("Unsupported OS/ARCH: %s/%s", os, arch))
} }
fileName := fmt.Sprintf(`tailwindcss-%s`, distro) fileName := fmt.Sprintf(`tailwindcss-%s`, distro)
url := fmt.Sprintf(`https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.16/%s`, fileName) url := fmt.Sprintf(`https://github.com/tailwindlabs/tailwindcss/releases/latest/download/%s`, fileName)
cmd := fmt.Sprintf(`curl -LO %s`, url) cmd := fmt.Sprintf(`curl -LO %s`, url)
process.Run(process.NewRawCommand("tailwind-cli-download", cmd, process.ExitOnError)) process.Run(process.NewRawCommand("tailwind-cli-download", cmd, process.ExitOnError))

View file

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

View file

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

View file

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

View file

@ -9,23 +9,12 @@ import (
"os" "os"
) )
func MakeBuildable() {
copyassets.CopyAssets()
css.GenerateCss(process.ExitOnError)
astgen.GenAst(process.ExitOnError)
}
func Build() { func Build() {
MakeBuildable() copyassets.CopyAssets()
astgen.GenAst(process.ExitOnError)
css.GenerateCss(process.ExitOnError)
_ = os.RemoveAll("./dist") process.RunOrExit(process.NewRawCommand("", "mkdir -p ./dist"))
err := os.Mkdir("./dist", 0755)
if err != nil {
fmt.Println("Error creating dist directory", err)
os.Exit(1)
}
if os.Getenv("SKIP_GO_BUILD") != "1" { if os.Getenv("SKIP_GO_BUILD") != "1" {
process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -tags prod -o ./dist"))) process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -tags prod -o ./dist")))

View file

@ -1,42 +1,7 @@
package run package run
import ( import "github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"io/fs"
"os"
"path/filepath"
)
func Server(flags ...process.RunFlag) error { func Server(flags ...process.RunFlag) error {
buildDir := "./__htmgo/temp-build" return process.Run(process.NewRawCommand("run-server", "go run .", flags...))
_ = os.RemoveAll(buildDir)
err := os.Mkdir(buildDir, 0755)
if err != nil {
return err
}
process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -o %s", buildDir)))
binaryPath := ""
// find the binary that was built
err = filepath.WalkDir(buildDir, func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
return nil
}
binaryPath = path
return nil
})
if err != nil {
return err
}
if binaryPath == "" {
return fmt.Errorf("could not find the binary")
}
return process.Run(process.NewRawCommand("run-server", fmt.Sprintf("./%s", binaryPath), flags...))
} }

View file

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

View file

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

View file

@ -89,7 +89,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
if !ok { if !ok {
return return
} }
slog.Error("error:", slog.String("error", err.Error())) slog.Error("error:", err.Error())
} }
} }
}() }()
@ -118,7 +118,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
if info.IsDir() { if info.IsDir() {
err = watcher.Add(path) err = watcher.Add(path)
if err != nil { if err != nil {
slog.Error("Error adding directory to watcher:", slog.String("error", err.Error())) slog.Error("Error adding directory to watcher:", err)
} else { } else {
slog.Debug("Watching directory:", slog.String("path", path)) slog.Debug("Watching directory:", slog.String("path", path))
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@ go 1.23.0
require ( require (
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78
github.com/mattn/go-sqlite3 v1.14.23 github.com/mattn/go-sqlite3 v1.14.23
github.com/puzpuzpuz/xsync/v3 v3.4.0 github.com/puzpuzpuz/xsync/v3 v3.4.0
) )

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4= github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78 h1:Z9JgL4GqRPfxCRKllB6qjtfYySdrp0xVSxWnepj9/18=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY= github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View file

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

View file

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

View file

@ -50,6 +50,7 @@ func Handle() http.HandlerFunc {
defer manager.Disconnect(sessionId) defer manager.Disconnect(sessionId)
defer func() { defer func() {
fmt.Printf("empting channels\n")
for len(writer) > 0 { for len(writer) > 0 {
<-writer <-writer
} }

View file

@ -70,14 +70,16 @@ func (manager *SocketManager) Listen(listener chan SocketEvent) {
} }
func (manager *SocketManager) dispatch(event SocketEvent) { func (manager *SocketManager) dispatch(event SocketEvent) {
fmt.Printf("dispatching event: %s\n", event.Type)
done := make(chan struct{}, 1) done := make(chan struct{}, 1)
go func() { go func() {
for { for {
select { select {
case <-done: case <-done:
fmt.Printf("dispatched event: %s\n", event.Type)
return return
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
fmt.Printf("havent dispatched listener event after 5s, chan blocked: %s\n", event.Type) fmt.Printf("havent dispatched event after 5s, chan blocked: %s\n", event.Type)
} }
} }
}() }()

View file

@ -2,15 +2,9 @@ module hackernews
go 1.23.0 go 1.23.0
require ( require github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/microcosm-cc/bluemonday v1.0.27
)
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
golang.org/x/net v0.29.0 // indirect
) )

View file

@ -1,17 +1,11 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78 h1:Z9JgL4GqRPfxCRKllB6qjtfYySdrp0xVSxWnepj9/18=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +0,0 @@
Minimal example that just uses htmgo for html rendering / js support and nothing else.
Removes automatic support for:
1. live reloading
2. tailwind recompilation
3. page/partial route registration
4. Single binary (since /public/ assets is required to be there), normally htmgo uses the embedded file system in other examples such as https://github.com/maddalax/htmgo/blob/master/templates/starter/assets_prod.go

View file

@ -1,10 +0,0 @@
module minimal-htmgo
go 1.23.0
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
)
require github.com/google/uuid v1.6.0 // indirect

View file

@ -1,16 +0,0 @@
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.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,44 +0,0 @@
package main
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/js"
"time"
)
func Index(ctx *h.RequestContext) *h.Page {
return h.NewPage(
h.Html(
h.HxExtensions(
h.BaseExtensions(),
),
h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Script("/public/htmgo.js"),
),
h.Body(
h.Pf("hello htmgo"),
h.Div(
h.Get("/current-time", "load, every 1s"),
),
h.Div(
h.Button(
h.Text("Click me"),
h.OnClick(
js.EvalJs(`
console.log("you evalulated javascript");
alert("you clicked me");
`),
),
),
),
),
),
)
}
func CurrentTime(ctx *h.RequestContext) *h.Partial {
return h.NewPartial(
h.Pf("It is %s", time.Now().String()),
)
}

View file

@ -1,23 +0,0 @@
package main
import (
"github.com/go-chi/chi/v5"
"net/http"
)
func main() {
router := chi.NewRouter()
fileServer := http.StripPrefix("/public", http.FileServer(http.Dir("./public")))
router.Handle("/public/*", fileServer)
router.Get("/", func(writer http.ResponseWriter, request *http.Request) {
RenderPage(request, writer, Index)
})
router.Get("/current-time", func(writer http.ResponseWriter, request *http.Request) {
RenderPartial(request, writer, CurrentTime)
})
http.ListenAndServe(":3000", router)
}

File diff suppressed because one or more lines are too long

View file

@ -1,26 +0,0 @@
package main
import (
"github.com/maddalax/htmgo/framework/h"
"net/http"
)
func RenderToString(element *h.Element) string {
return h.Render(element)
}
func RenderPage(req *http.Request, w http.ResponseWriter, page func(ctx *h.RequestContext) *h.Page) {
ctx := h.RequestContext{
Request: req,
Response: w,
}
h.HtmlView(w, page(&ctx))
}
func RenderPartial(req *http.Request, w http.ResponseWriter, partial func(ctx *h.RequestContext) *h.Partial) {
ctx := h.RequestContext{
Request: req,
Response: w,
}
h.PartialView(w, partial(&ctx))
}

View file

@ -3,7 +3,7 @@ module simpleauth
go 1.23.0 go 1.23.0
require ( require (
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.28.0
) )

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4= github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78 h1:Z9JgL4GqRPfxCRKllB6qjtfYySdrp0xVSxWnepj9/18=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY= github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View file

@ -24,16 +24,14 @@ func UserProfilePage(u db.User) *h.Element {
return h.Div( return h.Div(
h.Class("flex flex-col gap-6 items-center pt-10 min-h-screen bg-neutral-100"), h.Class("flex flex-col gap-6 items-center pt-10 min-h-screen bg-neutral-100"),
h.H3F( h.H3F("User Profile", h.Class("text-2xl font-bold")),
"User Profile",
h.Class("text-2xl font-bold"),
),
h.Pf("Welcome, %s!", u.Email), h.Pf("Welcome, %s!", u.Email),
h.Form( h.Form(
h.Attribute("hx-swap", "none"), h.Attribute("hx-swap", "none"),
h.PostPartial(partials.UpdateProfile), h.PostPartial(partials.UpdateProfile),
h.TriggerChildren(), h.TriggerChildren(),
h.Class("flex flex-col gap-4 w-full max-w-md p-6 bg-white rounded-md shadow-md"), h.Class("flex flex-col gap-4 w-full max-w-md p-6 bg-white rounded-md shadow-md"),
ui.Input(ui.InputProps{ ui.Input(ui.InputProps{
Id: "email", Id: "email",
Name: "email", Name: "email",
@ -44,22 +42,26 @@ func UserProfilePage(u db.User) *h.Element {
h.Disabled(), h.Disabled(),
}, },
}), }),
ui.Input(ui.InputProps{ ui.Input(ui.InputProps{
Name: "birth-date", Name: "birth-date",
Label: "Birth Date", Label: "Birth Date",
DefaultValue: user.GetMetaKey(meta, "birthDate"), DefaultValue: user.GetMetaKey(meta, "birthDate"),
Type: "date", Type: "date",
}), }),
ui.Input(ui.InputProps{ ui.Input(ui.InputProps{
Name: "favorite-color", Name: "favorite-color",
Label: "Favorite Color", Label: "Favorite Color",
DefaultValue: user.GetMetaKey(meta, "favoriteColor"), DefaultValue: user.GetMetaKey(meta, "favoriteColor"),
}), }),
ui.Input(ui.InputProps{ ui.Input(ui.InputProps{
Name: "occupation", Name: "occupation",
Label: "Occupation", Label: "Occupation",
DefaultValue: user.GetMetaKey(meta, "occupation"), DefaultValue: user.GetMetaKey(meta, "occupation"),
}), }),
ui.FormError(""), ui.FormError(""),
ui.SubmitButton("Save Changes"), ui.SubmitButton("Save Changes"),
), ),

View file

@ -6,9 +6,7 @@ import (
func RootPage(children ...h.Ren) h.Ren { func RootPage(children ...h.Ren) h.Ren {
return h.Html( return h.Html(
h.HxExtensions( h.HxExtensions(h.BaseExtensions()),
h.BaseExtensions(),
),
h.Head( h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"), h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Link("/public/favicon.ico", "icon"), h.Link("/public/favicon.ico", "icon"),

View file

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

View file

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

View file

@ -16,10 +16,7 @@ func CenteredForm(props CenteredFormProps) *h.Element {
h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"), h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"),
h.Div( h.Div(
h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"), h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"),
h.H2F( h.H2F(props.Title, h.Class("text-3xl font-bold text-center mb-6")),
props.Title,
h.Class("text-3xl font-bold text-center mb-6"),
),
h.Form( h.Form(
h.TriggerChildren(), h.TriggerChildren(),
h.Post(props.PostUrl), h.Post(props.PostUrl),

View file

@ -5,7 +5,7 @@ go 1.23.0
require ( require (
entgo.io/ent v0.14.1 entgo.io/ent v0.14.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78
github.com/mattn/go-sqlite3 v1.14.23 github.com/mattn/go-sqlite3 v1.14.23
) )

View file

@ -33,8 +33,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4= github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78 h1:Z9JgL4GqRPfxCRKllB6qjtfYySdrp0xVSxWnepj9/18=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY= github.com/maddalax/htmgo/framework v0.0.0-20241020152137-b6d901fadf78/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,38 +0,0 @@
# Stage 1: Build the Go binary
FROM golang:1.23-alpine AS builder
RUN apk update
RUN apk add git
RUN apk add curl
# 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 GOPRIVATE=github.com/maddalax GOPROXY=direct go run github.com/maddalax/htmgo/cli/htmgo@latest build
# 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 ["./ws-example"]

View file

@ -1,20 +0,0 @@
version: '3'
tasks:
run:
cmds:
- go run github.com/maddalax/htmgo/cli/htmgo@latest run
silent: true
build:
cmds:
- go run github.com/maddalax/htmgo/cli/htmgo@latest build
docker:
cmds:
- docker build .
watch:
cmds:
- go run github.com/maddalax/htmgo/cli/htmgo@latest watch
silent: true

View file

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

View file

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

View file

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

View file

@ -1,18 +0,0 @@
module ws-example
go 1.23.0
require (
github.com/maddalax/htmgo/extensions/websocket v0.0.0-20241109180553-34e816ff7c8a
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
)
require (
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
golang.org/x/sys v0.6.0 // indirect
)

View file

@ -1,28 +0,0 @@
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/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
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/extensions/websocket v0.0.0-20241109180553-34e816ff7c8a h1:BYVo9NCLHgXvf5pCGUnVg8UE7d9mWOyLgWXYTgVTkyA=
github.com/maddalax/htmgo/extensions/websocket v0.0.0-20241109180553-34e816ff7c8a/go.mod h1:r6/VqntLp7VlAUpIXy3MWZMHs2EkPKJP5rJdDL8lFP4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/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/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
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=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

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

View file

@ -1,48 +0,0 @@
package main
import (
"github.com/maddalax/htmgo/extensions/websocket"
ws2 "github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
"ws-example/__htmgo"
)
func main() {
locator := service.NewLocator()
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
app.Use(func(ctx *h.RequestContext) {
session.CreateSession(ctx)
})
websocket.EnableExtension(app, ws2.ExtensionOpts{
WsPath: "/ws",
RoomName: func(ctx *h.RequestContext) string {
return "all"
},
SessionId: func(ctx *h.RequestContext) string {
return ctx.QueryParam("sessionId")
},
})
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
__htmgo.Register(app.Router)
},
})
}

View file

@ -1,57 +0,0 @@
package pages
import (
"fmt"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
"ws-example/partials"
)
func IndexPage(ctx *h.RequestContext) *h.Page {
sessionId := session.GetSessionId(ctx)
return h.NewPage(
RootPage(
ctx,
h.Div(
h.Attribute("ws-connect", fmt.Sprintf("/ws?sessionId=%s", sessionId)),
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("Repeater Example"),
h.Class("text-2xl"),
),
h.Div(
h.Id("ws-metrics"),
),
partials.CounterForm(ctx, partials.CounterProps{Id: "counter-1"}),
partials.Repeater(ctx, partials.RepeaterProps{
Id: "repeater-1",
OnAdd: func(data ws.HandlerData) {
//ws.BroadcastServerSideEvent("increment", map[string]any{})
},
OnRemove: func(data ws.HandlerData, index int) {
//ws.BroadcastServerSideEvent("decrement", map[string]any{})
},
AddButton: h.Button(
h.Text("+ Add Item"),
),
RemoveButton: func(index int, children ...h.Ren) *h.Element {
return h.Button(
h.Text("Remove"),
h.Children(children...),
)
},
Item: func(index int) *h.Element {
return h.Input(
"text",
h.Class("border border-gray-300 rounded p-2"),
h.Value(fmt.Sprintf("item %d", index)),
)
},
}),
),
),
)
}

View file

@ -1,26 +0,0 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func RootPage(ctx *h.RequestContext, children ...h.Ren) h.Ren {
return h.Html(
h.JoinExtensions(
h.HxExtension(
h.BaseExtensions(),
),
h.HxExtension("ws"),
),
h.Head(
h.Link("/public/main.css", "stylesheet"),
h.Script("/public/htmgo.js"),
),
h.Body(
h.Div(
h.Class("flex flex-col gap-2 bg-white h-full"),
h.Fragment(children...),
),
),
)
}

View file

@ -1,129 +0,0 @@
package ws
import (
"fmt"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
"runtime"
"time"
"ws-example/pages"
)
func Metrics(ctx *h.RequestContext) *h.Page {
ws.RunOnConnected(ctx, func() {
ws.PushElementCtx(
ctx,
metricsView(ctx),
)
})
ws.Every(ctx, time.Second, func() bool {
return ws.PushElementCtx(
ctx,
metricsView(ctx),
)
})
return h.NewPage(
pages.RootPage(
ctx,
h.Div(
h.Attribute("ws-connect", fmt.Sprintf("/ws?sessionId=%s", session.GetSessionId(ctx))),
h.Class("flex flex-col gap-4 items-center min-h-screen max-w-2xl mx-auto mt-8"),
h.H3(
h.Id("intro-text"),
h.Text("Websocket Metrics"),
h.Class("text-2xl"),
),
h.Div(
h.Id("ws-metrics"),
),
),
),
)
}
func metricsView(ctx *h.RequestContext) *h.Element {
metrics := ws.MetricsFromCtx(ctx)
return h.Div(
h.Id("ws-metrics"),
List(metrics),
)
}
func List(metrics ws.Metrics) *h.Element {
return h.Body(
h.Div(
h.Class("flow-root rounded-lg border border-gray-100 py-3 shadow-sm"),
h.Dl(
h.Class("-my-3 divide-y divide-gray-100 text-sm"),
ListItem("Current Time", time.Now().Format("15:04:05")),
ListItem("Seconds Elapsed", fmt.Sprintf("%d", metrics.Manager.SecondsElapsed)),
ListItem("Total Messages", fmt.Sprintf("%d", metrics.Manager.TotalMessages)),
ListItem("Messages Per Second", fmt.Sprintf("%d", metrics.Manager.MessagesPerSecond)),
ListItem("Total Goroutines For ws.Every", fmt.Sprintf("%d", metrics.Manager.RunningGoroutines)),
ListItem("Total Goroutines In System", fmt.Sprintf("%d", runtime.NumGoroutine())),
ListItem("Sockets", fmt.Sprintf("%d", metrics.Manager.TotalSockets)),
ListItem("Rooms", fmt.Sprintf("%d", metrics.Manager.TotalRooms)),
ListItem("Session Id To Hashes", fmt.Sprintf("%d", metrics.Handler.SessionIdToHashesCount)),
ListItem("Total Handlers", fmt.Sprintf("%d", metrics.Handler.TotalHandlers)),
ListItem("Server Event Names To Hash", fmt.Sprintf("%d", metrics.Handler.ServerEventNamesToHashCount)),
ListItem("Total Listeners", fmt.Sprintf("%d", metrics.Manager.TotalListeners)),
h.IterMap(metrics.Manager.SocketsPerRoom, func(key string, value []string) *h.Element {
return ListBlock(
fmt.Sprintf("Sockets In Room - %s", key),
h.IfElse(
len(value) > 100,
h.Div(
h.Pf("%d total sockets", len(value)),
),
h.Div(
h.List(value, func(item string, index int) *h.Element {
return h.Div(
h.Pf("%s", item),
)
}),
),
),
)
}),
),
),
)
}
func ListItem(term, description string) *h.Element {
return h.Div(
h.Class("grid grid-cols-1 gap-1 p-3 even:bg-gray-50 sm:grid-cols-3 sm:gap-4"),
DescriptionTerm(term),
DescriptionDetail(description),
)
}
func ListBlock(title string, children *h.Element) *h.Element {
return h.Div(
h.Class("grid grid-cols-1 gap-1 p-3 even:bg-gray-50 sm:grid-cols-3 sm:gap-4"),
DescriptionTerm(title),
h.Dd(
h.Class("text-gray-700 sm:col-span-2"),
children,
),
)
}
func DescriptionTerm(term string) *h.Element {
return h.Dt(
h.Class("font-medium text-gray-900"),
h.Text(term),
)
}
func DescriptionDetail(detail string) *h.Element {
return h.Dd(
h.Class("text-gray-700 sm:col-span-2"),
h.Text(detail),
)
}

View file

@ -1,72 +0,0 @@
package partials
import (
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
)
type Counter struct {
Count func() int
Increment func()
Decrement func()
}
func UseCounter(ctx *h.RequestContext, id string) Counter {
sessionId := session.GetSessionId(ctx)
get, set := session.UseState(sessionId, id, 0)
var increment = func() {
set(get() + 1)
}
var decrement = func() {
set(get() - 1)
}
return Counter{
Count: get,
Increment: increment,
Decrement: decrement,
}
}
type CounterProps struct {
Id string
}
func CounterForm(ctx *h.RequestContext, props CounterProps) *h.Element {
if props.Id == "" {
props.Id = h.GenId(6)
}
counter := UseCounter(ctx, props.Id)
return h.Div(
h.Attribute("hx-swap", "none"),
h.Class("flex flex-col gap-3 items-center"),
h.Id(props.Id),
h.P(
h.Id("counter-text-"+props.Id),
h.AttributePairs(
"id", "counter",
"class", "text-xl",
"name", "count",
"text", "count",
),
h.TextF("Count: %d", counter.Count()),
),
h.Button(
h.Class("bg-rose-400 hover:bg-rose-500 text-white font-bold py-2 px-4 rounded"),
h.Type("submit"),
h.Text("Increment"),
ws.OnServerEvent(ctx, "increment", func(data ws.HandlerData) {
counter.Increment()
ws.PushElement(data, CounterForm(ctx, props))
}),
ws.OnServerEvent(ctx, "decrement", func(data ws.HandlerData) {
counter.Decrement()
ws.PushElement(data, CounterForm(ctx, props))
}),
),
)
}

View file

@ -1,84 +0,0 @@
package partials
import (
"fmt"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
)
type RepeaterProps struct {
Item func(index int) *h.Element
RemoveButton func(index int, children ...h.Ren) *h.Element
AddButton *h.Element
DefaultItems []*h.Element
Id string
currentIndex int
OnAdd func(data ws.HandlerData)
OnRemove func(data ws.HandlerData, index int)
}
func (props *RepeaterProps) itemId(index int) string {
return fmt.Sprintf("%s-repeater-item-%d", props.Id, index)
}
func (props *RepeaterProps) addButtonId() string {
return fmt.Sprintf("%s-repeater-add-button", props.Id)
}
func repeaterItem(ctx *h.RequestContext, item *h.Element, index int, props *RepeaterProps) *h.Element {
id := props.itemId(index)
return h.Div(
h.Class("flex gap-2 items-center"),
h.Id(id),
item,
props.RemoveButton(
index,
h.ClassIf(index == 0, "opacity-0 disabled"),
h.If(
index == 0,
h.Disabled(),
),
ws.OnClick(ctx, func(data ws.HandlerData) {
props.OnRemove(data, index)
props.currentIndex--
ws.PushElement(
data,
h.Div(
h.Attribute("hx-swap-oob", fmt.Sprintf("delete:#%s", id)),
h.Div(),
),
)
}),
),
)
}
func Repeater(ctx *h.RequestContext, props RepeaterProps) *h.Element {
if props.Id == "" {
props.Id = h.GenId(6)
}
return h.Div(
h.Class("flex flex-col gap-2"),
h.List(props.DefaultItems, func(item *h.Element, index int) *h.Element {
return repeaterItem(ctx, item, index, &props)
}),
h.Div(
h.Id(props.addButtonId()),
h.Class("flex justify-center"),
props.AddButton,
ws.OnClick(ctx, func(data ws.HandlerData) {
props.OnAdd(data)
ws.PushElement(
data,
h.Div(
h.Attribute("hx-swap-oob", "beforebegin:#"+props.addButtonId()),
repeaterItem(
ctx, props.Item(props.currentIndex), props.currentIndex, &props,
),
),
)
props.currentIndex++
}),
),
)
}

View file

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

View file

@ -1,21 +0,0 @@
module github.com/maddalax/htmgo/extensions/websocket
go 1.23.0
require (
github.com/gobwas/ws v1.4.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/stretchr/testify v1.9.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,28 +0,0 @@
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/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
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.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/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/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
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=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,31 +0,0 @@
package websocket
import (
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
)
func EnableExtension(app *h.App, opts opts.ExtensionOpts) {
if app.Opts.ServiceLocator == nil {
app.Opts.ServiceLocator = service.NewLocator()
}
if opts.WsPath == "" {
panic("websocket: WsPath is required")
}
if opts.SessionId == nil {
panic("websocket: SessionId func is required")
}
service.Set[wsutil.SocketManager](app.Opts.ServiceLocator, service.Singleton, func() *wsutil.SocketManager {
manager := wsutil.NewSocketManager(&opts)
manager.StartMetrics()
return manager
})
ws.StartListener(app.Opts.ServiceLocator)
app.Router.Handle(opts.WsPath, wsutil.WsHttpHandler(&opts))
}

View file

@ -1,115 +0,0 @@
package wsutil
import (
"encoding/json"
"fmt"
"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
ws2 "github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"log/slog"
"net/http"
"sync"
"time"
)
func WsHttpHandler(opts *ws2.ExtensionOpts) http.HandlerFunc {
if opts.RoomName == nil {
opts.RoomName = func(ctx *h.RequestContext) string {
return "all"
}
}
return func(w http.ResponseWriter, r *http.Request) {
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
locator := cc.ServiceLocator()
manager := service.Get[SocketManager](locator)
sessionId := opts.SessionId(cc)
if sessionId == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
conn, _, _, err := ws.UpgradeHTTP(r, w)
if err != nil {
slog.Info("failed to upgrade", slog.String("error", err.Error()))
return
}
roomId := opts.RoomName(cc)
/*
Large buffer in case the client disconnects while we are writing
we don't want to block the writer
*/
done := make(chan bool, 1000)
writer := make(WriterChan, 1000)
wg := sync.WaitGroup{}
manager.Add(roomId, sessionId, writer, done)
/*
* This goroutine is responsible for writing messages to the client
*/
wg.Add(1)
go func() {
defer manager.Disconnect(sessionId)
defer wg.Done()
defer func() {
for len(writer) > 0 {
<-writer
}
for len(done) > 0 {
<-done
}
}()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Printf("closing connection: \n")
return
case <-ticker.C:
manager.Ping(sessionId)
case message := <-writer:
err = wsutil.WriteServerMessage(conn, ws.OpText, []byte(message))
if err != nil {
return
}
}
}
}()
/*
* This goroutine is responsible for reading messages from the client
*/
go func() {
defer conn.Close()
for {
msg, op, err := wsutil.ReadClientData(conn)
if err != nil {
return
}
if op != ws.OpText {
return
}
m := make(map[string]any)
err = json.Unmarshal(msg, &m)
if err != nil {
return
}
manager.OnMessage(sessionId, m)
}
}()
wg.Wait()
}
}

View file

@ -1,365 +0,0 @@
package wsutil
import (
"fmt"
"github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"github.com/puzpuzpuz/xsync/v3"
"log/slog"
"strings"
"sync"
"sync/atomic"
"time"
)
type EventType string
type WriterChan chan string
type DoneChan chan bool
const (
ConnectedEvent EventType = "connected"
DisconnectedEvent EventType = "disconnected"
MessageEvent EventType = "message"
)
type SocketEvent struct {
SessionId string
RoomId string
Type EventType
Payload map[string]any
}
type CloseEvent struct {
Code int
Reason string
}
type SocketConnection struct {
Id string
RoomId string
Done DoneChan
Writer WriterChan
}
type ManagerMetrics struct {
RunningGoroutines int32
TotalSockets int
TotalRooms int
TotalListeners int
SocketsPerRoomCount map[string]int
SocketsPerRoom map[string][]string
TotalMessages int64
MessagesPerSecond int
SecondsElapsed int
}
type SocketManager struct {
sockets *xsync.MapOf[string, *xsync.MapOf[string, SocketConnection]]
idToRoom *xsync.MapOf[string, string]
listeners []chan SocketEvent
goroutinesRunning atomic.Int32
opts *opts.ExtensionOpts
lock sync.Mutex
totalMessages atomic.Int64
messagesPerSecond int
secondsElapsed int
}
func (manager *SocketManager) StartMetrics() {
go func() {
for {
time.Sleep(time.Second)
manager.lock.Lock()
manager.secondsElapsed++
totalMessages := manager.totalMessages.Load()
manager.messagesPerSecond = int(float64(totalMessages) / float64(manager.secondsElapsed))
manager.lock.Unlock()
}
}()
}
func (manager *SocketManager) Metrics() ManagerMetrics {
manager.lock.Lock()
defer manager.lock.Unlock()
count := manager.goroutinesRunning.Load()
metrics := ManagerMetrics{
RunningGoroutines: count,
TotalSockets: 0,
TotalRooms: 0,
TotalListeners: len(manager.listeners),
SocketsPerRoom: make(map[string][]string),
SocketsPerRoomCount: make(map[string]int),
TotalMessages: manager.totalMessages.Load(),
MessagesPerSecond: manager.messagesPerSecond,
SecondsElapsed: manager.secondsElapsed,
}
roomMap := make(map[string]int)
manager.idToRoom.Range(func(socketId string, roomId string) bool {
roomMap[roomId]++
return true
})
metrics.TotalRooms = len(roomMap)
manager.sockets.Range(func(roomId string, sockets *xsync.MapOf[string, SocketConnection]) bool {
metrics.SocketsPerRoomCount[roomId] = sockets.Size()
sockets.Range(func(socketId string, conn SocketConnection) bool {
if metrics.SocketsPerRoom[roomId] == nil {
metrics.SocketsPerRoom[roomId] = []string{}
}
metrics.SocketsPerRoom[roomId] = append(metrics.SocketsPerRoom[roomId], socketId)
metrics.TotalSockets++
return true
})
return true
})
return metrics
}
func SocketManagerFromCtx(ctx *h.RequestContext) *SocketManager {
locator := ctx.ServiceLocator()
return service.Get[SocketManager](locator)
}
func NewSocketManager(opts *opts.ExtensionOpts) *SocketManager {
return &SocketManager{
sockets: xsync.NewMapOf[string, *xsync.MapOf[string, SocketConnection]](),
idToRoom: xsync.NewMapOf[string, string](),
opts: opts,
goroutinesRunning: atomic.Int32{},
}
}
func (manager *SocketManager) ForEachSocket(roomId string, cb func(conn SocketConnection)) {
sockets, ok := manager.sockets.Load(roomId)
if !ok {
return
}
sockets.Range(func(id string, conn SocketConnection) bool {
cb(conn)
return true
})
}
func (manager *SocketManager) RunIntervalWithSocket(socketId string, interval time.Duration, cb func() bool) {
socketIdSlog := slog.String("socketId", socketId)
slog.Debug("ws-extension: starting every loop", socketIdSlog, slog.Duration("duration", interval))
go func() {
manager.goroutinesRunning.Add(1)
defer manager.goroutinesRunning.Add(-1)
tries := 0
for {
socket := manager.Get(socketId)
// This can run before the socket is established, lets try a few times and kill it if socket isn't connected after a bit.
if socket == nil {
if tries > 200 {
slog.Debug("ws-extension: socket disconnected, killing goroutine", socketIdSlog)
return
} else {
time.Sleep(time.Millisecond * 15)
tries++
slog.Debug("ws-extension: socket not connected yet, trying again", socketIdSlog, slog.Int("attempt", tries))
continue
}
}
success := cb()
if !success {
return
}
time.Sleep(interval)
}
}()
}
func (manager *SocketManager) Listen(listener chan SocketEvent) {
if manager.listeners == nil {
manager.listeners = make([]chan SocketEvent, 0)
}
if listener != nil {
manager.listeners = append(manager.listeners, listener)
}
}
func (manager *SocketManager) dispatch(event SocketEvent) {
done := make(chan struct{}, 1)
go func() {
for {
select {
case <-done:
return
case <-time.After(5 * time.Second):
fmt.Printf("havent dispatched event after 5s, chan blocked: %s\n", event.Type)
}
}
}()
for _, listener := range manager.listeners {
listener <- event
}
done <- struct{}{}
}
func (manager *SocketManager) OnMessage(id string, message map[string]any) {
socket := manager.Get(id)
if socket == nil {
return
}
manager.totalMessages.Add(1)
manager.dispatch(SocketEvent{
SessionId: id,
Type: MessageEvent,
Payload: message,
RoomId: socket.RoomId,
})
}
func (manager *SocketManager) Add(roomId string, id string, writer WriterChan, done DoneChan) {
manager.idToRoom.Store(id, roomId)
sockets, ok := manager.sockets.LoadOrCompute(roomId, func() *xsync.MapOf[string, SocketConnection] {
return xsync.NewMapOf[string, SocketConnection]()
})
sockets.Store(id, SocketConnection{
Id: id,
Writer: writer,
RoomId: roomId,
Done: done,
})
s, ok := sockets.Load(id)
if !ok {
return
}
manager.dispatch(SocketEvent{
SessionId: s.Id,
Type: ConnectedEvent,
RoomId: s.RoomId,
Payload: map[string]any{},
})
}
func (manager *SocketManager) OnClose(id string) {
socket := manager.Get(id)
if socket == nil {
return
}
slog.Debug("ws-extension: removing socket from manager", slog.String("socketId", id))
manager.dispatch(SocketEvent{
SessionId: id,
Type: DisconnectedEvent,
RoomId: socket.RoomId,
Payload: map[string]any{},
})
roomId, ok := manager.idToRoom.Load(id)
if !ok {
return
}
sockets, ok := manager.sockets.Load(roomId)
if !ok {
return
}
sockets.Delete(id)
manager.idToRoom.Delete(id)
slog.Debug("ws-extension: removed socket from manager", slog.String("socketId", id))
}
func (manager *SocketManager) CloseWithMessage(id string, message string) {
conn := manager.Get(id)
if conn != nil {
defer manager.OnClose(id)
manager.writeText(*conn, message)
conn.Done <- true
}
}
func (manager *SocketManager) Disconnect(id string) {
conn := manager.Get(id)
if conn != nil {
manager.OnClose(id)
conn.Done <- true
}
}
func (manager *SocketManager) Get(id string) *SocketConnection {
roomId, ok := manager.idToRoom.Load(id)
if !ok {
return nil
}
sockets, ok := manager.sockets.Load(roomId)
if !ok {
return nil
}
conn, ok := sockets.Load(id)
return &conn
}
func (manager *SocketManager) Ping(id string) bool {
conn := manager.Get(id)
if conn != nil {
return manager.writeText(*conn, "ping")
}
return false
}
func (manager *SocketManager) writeCloseRaw(writer WriterChan, message string) {
manager.writeTextRaw(writer, message)
}
func (manager *SocketManager) writeTextRaw(writer WriterChan, message string) {
timeout := 3 * time.Second
select {
case writer <- message:
case <-time.After(timeout):
fmt.Printf("could not send %s to channel after %s\n", message, timeout)
}
}
func (manager *SocketManager) writeText(socket SocketConnection, message string) bool {
if socket.Writer == nil {
return false
}
manager.writeTextRaw(socket.Writer, message)
return true
}
func (manager *SocketManager) BroadcastText(roomId string, message string, predicate func(conn SocketConnection) bool) {
sockets, ok := manager.sockets.Load(roomId)
if !ok {
return
}
sockets.Range(func(id string, conn SocketConnection) bool {
if predicate(conn) {
manager.writeText(conn, message)
}
return true
})
}
func (manager *SocketManager) SendHtml(id string, message string) bool {
conn := manager.Get(id)
minified := strings.ReplaceAll(message, "\n", "")
minified = strings.ReplaceAll(minified, "\t", "")
minified = strings.TrimSpace(minified)
if conn != nil {
return manager.writeText(*conn, minified)
}
return false
}
func (manager *SocketManager) SendText(id string, message string) bool {
conn := manager.Get(id)
if conn != nil {
return manager.writeText(*conn, message)
}
return false
}

View file

@ -1,202 +0,0 @@
package wsutil
import (
ws2 "github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/framework/h"
"github.com/stretchr/testify/assert"
"testing"
)
func createManager() *SocketManager {
return NewSocketManager(&ws2.ExtensionOpts{
WsPath: "/ws",
SessionId: func(ctx *h.RequestContext) string {
return "test"
},
})
}
func addSocket(manager *SocketManager, roomId string, id string) (socketId string, writer WriterChan, done DoneChan) {
writer = make(chan string, 10)
done = make(chan bool, 10)
manager.Add(roomId, id, writer, done)
return id, writer, done
}
func TestManager(t *testing.T) {
manager := createManager()
socketId, _, _ := addSocket(manager, "123", "456")
socket := manager.Get(socketId)
assert.NotNil(t, socket)
assert.Equal(t, socketId, socket.Id)
manager.OnClose(socketId)
socket = manager.Get(socketId)
assert.Nil(t, socket)
}
func TestManagerForEachSocket(t *testing.T) {
manager := createManager()
addSocket(manager, "all", "456")
addSocket(manager, "all", "789")
var count int
manager.ForEachSocket("all", func(conn SocketConnection) {
count++
})
assert.Equal(t, 2, count)
}
func TestSendText(t *testing.T) {
manager := createManager()
socketId, writer, done := addSocket(manager, "all", "456")
manager.SendText(socketId, "hello")
assert.Equal(t, "hello", <-writer)
manager.SendText(socketId, "hello2")
assert.Equal(t, "hello2", <-writer)
done <- true
assert.Equal(t, true, <-done)
}
func TestBroadcastText(t *testing.T) {
manager := createManager()
_, w1, d1 := addSocket(manager, "all", "456")
_, w2, d2 := addSocket(manager, "all", "789")
manager.BroadcastText("all", "hello", func(conn SocketConnection) bool {
return true
})
assert.Equal(t, "hello", <-w1)
assert.Equal(t, "hello", <-w2)
d1 <- true
d2 <- true
assert.Equal(t, true, <-d1)
assert.Equal(t, true, <-d2)
}
func TestBroadcastTextWithPredicate(t *testing.T) {
manager := createManager()
_, w1, _ := addSocket(manager, "all", "456")
_, w2, _ := addSocket(manager, "all", "789")
manager.BroadcastText("all", "hello", func(conn SocketConnection) bool {
return conn.Id != "456"
})
assert.Equal(t, 0, len(w1))
assert.Equal(t, 1, len(w2))
}
func TestSendHtml(t *testing.T) {
manager := createManager()
socketId, writer, _ := addSocket(manager, "all", "456")
rendered := h.Render(
h.Div(
h.P(
h.Text("hello"),
),
))
manager.SendHtml(socketId, rendered)
assert.Equal(t, "<div><p>hello</p></div>", <-writer)
}
func TestOnMessage(t *testing.T) {
manager := createManager()
socketId, _, _ := addSocket(manager, "all", "456")
listener := make(chan SocketEvent, 10)
manager.Listen(listener)
manager.OnMessage(socketId, map[string]any{
"message": "hello",
})
event := <-listener
assert.Equal(t, "hello", event.Payload["message"])
assert.Equal(t, "456", event.SessionId)
assert.Equal(t, MessageEvent, event.Type)
assert.Equal(t, "all", event.RoomId)
}
func TestOnClose(t *testing.T) {
manager := createManager()
socketId, _, _ := addSocket(manager, "all", "456")
listener := make(chan SocketEvent, 10)
manager.Listen(listener)
manager.OnClose(socketId)
event := <-listener
assert.Equal(t, "456", event.SessionId)
assert.Equal(t, DisconnectedEvent, event.Type)
assert.Equal(t, "all", event.RoomId)
}
func TestOnAdd(t *testing.T) {
manager := createManager()
listener := make(chan SocketEvent, 10)
manager.Listen(listener)
socketId, _, _ := addSocket(manager, "all", "456")
event := <-listener
assert.Equal(t, socketId, event.SessionId)
assert.Equal(t, ConnectedEvent, event.Type)
assert.Equal(t, "all", event.RoomId)
}
func TestCloseWithMessage(t *testing.T) {
manager := createManager()
socketId, w, _ := addSocket(manager, "all", "456")
manager.CloseWithMessage(socketId, "internal error")
assert.Equal(t, "internal error", <-w)
assert.Nil(t, manager.Get(socketId))
}
func TestDisconnect(t *testing.T) {
manager := createManager()
socketId, _, _ := addSocket(manager, "all", "456")
manager.Disconnect(socketId)
assert.Nil(t, manager.Get(socketId))
}
func TestPing(t *testing.T) {
manager := createManager()
socketId, w, _ := addSocket(manager, "all", "456")
manager.Ping(socketId)
assert.Equal(t, "ping", <-w)
}
func TestMultipleRooms(t *testing.T) {
manager := createManager()
socketId1, _, _ := addSocket(manager, "room1", "456")
socketId2, _, _ := addSocket(manager, "room2", "789")
room1Count := 0
room2Count := 0
manager.ForEachSocket("room1", func(conn SocketConnection) {
room1Count++
})
manager.ForEachSocket("room2", func(conn SocketConnection) {
room2Count++
})
assert.Equal(t, 1, room1Count)
assert.Equal(t, 1, room2Count)
room1Count = 0
room2Count = 0
manager.OnClose(socketId1)
manager.OnClose(socketId2)
manager.ForEachSocket("room1", func(conn SocketConnection) {
room1Count++
})
manager.ForEachSocket("room2", func(conn SocketConnection) {
room2Count++
})
assert.Equal(t, 0, room1Count)
assert.Equal(t, 0, room2Count)
}

View file

@ -1,9 +0,0 @@
package opts
import "github.com/maddalax/htmgo/framework/h"
type ExtensionOpts struct {
WsPath string
RoomName func(ctx *h.RequestContext) string
SessionId func(ctx *h.RequestContext) string
}

View file

@ -1,77 +0,0 @@
package session
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/puzpuzpuz/xsync/v3"
)
type Id string
var cache = xsync.NewMapOf[Id, *xsync.MapOf[string, any]]()
type State struct {
SessionId Id
}
func NewState(ctx *h.RequestContext) *State {
id := GetSessionId(ctx)
cache.Store(id, xsync.NewMapOf[string, any]())
return &State{
SessionId: id,
}
}
func CreateSession(ctx *h.RequestContext) Id {
sessionId := fmt.Sprintf("session-id-%s", h.GenId(30))
ctx.Set("session-id", sessionId)
return Id(sessionId)
}
func GetSessionId(ctx *h.RequestContext) Id {
sessionIdRaw := ctx.Get("session-id")
sessionId := ""
if sessionIdRaw == "" || sessionIdRaw == nil {
panic("session id is not set, please use session.CreateSession(ctx) in middleware to create a session id")
} else {
sessionId = sessionIdRaw.(string)
}
return Id(sessionId)
}
func Update[T any](sessionId Id, key string, compute func(prev T) T) T {
actual := Get[T](sessionId, key, *new(T))
next := compute(actual)
Set(sessionId, key, next)
return next
}
func Get[T any](sessionId Id, key string, fallback T) T {
actual, _ := cache.LoadOrCompute(sessionId, func() *xsync.MapOf[string, any] {
return xsync.NewMapOf[string, any]()
})
value, exists := actual.Load(key)
if exists {
return value.(T)
}
return fallback
}
func Set(sessionId Id, key string, value any) {
actual, _ := cache.LoadOrCompute(sessionId, func() *xsync.MapOf[string, any] {
return xsync.NewMapOf[string, any]()
})
actual.Store(key, value)
}
func UseState[T any](sessionId Id, key string, initial T) (func() T, func(T)) {
var get = func() T {
return Get[T](sessionId, key, initial)
}
var set = func(value T) {
Set(sessionId, key, value)
}
return get, set
}

View file

@ -1,10 +0,0 @@
package ws
import (
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/framework/h"
)
func ManagerFromCtx(ctx *h.RequestContext) *wsutil.SocketManager {
return wsutil.SocketManagerFromCtx(ctx)
}

View file

@ -1,20 +0,0 @@
package ws
import "github.com/maddalax/htmgo/framework/h"
func OnClick(ctx *h.RequestContext, handler Handler) *h.AttributeMapOrdered {
return AddClientSideHandler(ctx, "click", handler)
}
func OnClientEvent(ctx *h.RequestContext, eventName string, handler Handler) *h.AttributeMapOrdered {
return AddClientSideHandler(ctx, eventName, handler)
}
func OnServerEvent(ctx *h.RequestContext, eventName string, handler Handler) h.Ren {
AddServerSideHandler(ctx, eventName, handler)
return h.Attribute("data-handler-id", "")
}
func OnMouseOver(ctx *h.RequestContext, handler Handler) *h.AttributeMapOrdered {
return AddClientSideHandler(ctx, "mouseover", handler)
}

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