Merge remote-tracking branch 'origin/master'

# Conflicts:
#	framework/h/attribute.go
#	framework/h/lifecycle.go
#	framework/h/render.go
This commit is contained in:
maddalax 2024-10-25 22:01:04 -05:00
commit 3468baaa84
236 changed files with 5396 additions and 922 deletions

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [maddalax]

View file

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

View file

@ -1,9 +1,10 @@
name: Build and Deploy htmgo.dev chat example
on:
pull_request:
branches:
- master
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:

52
.github/workflows/release-hn-clone.yml vendored Normal file
View file

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

View file

@ -1,13 +1,16 @@
name: Build and Deploy htmgo.dev
on:
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:
- master # Trigger on pushes to master
paths:
- 'htmgo-site/**' # Trigger only if files in this directory change
- "framework/**"
- "framework-ui/**"
- "cli/**"

View file

@ -1,6 +1,10 @@
name: Build and Deploy starter template
on:
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:

View file

@ -1,6 +1,10 @@
name: Build and Deploy htmgo.dev todo example
on:
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:

View file

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

128
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
maddox@htmgo.dev.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View file

@ -1,6 +1,3 @@
> [!WARNING]
> htmgo is in alpha release. Please report any issues on GitHub.
## **htmgo**
### build simple and scalable systems with go + htmx
@ -34,9 +31,12 @@ func IndexPage(ctx *h.RequestContext) *h.Page {
2. live reload (rebuilds css, go, ent schema, and routes upon change)
3. automatic page and partial registration based on file path
4. built in tailwindcss support, no need to configure anything by default
5. plugin architecture to include optional plugins to streamline development, such as http://entgo.io
6. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
5. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
**get started:**
View documentation on [htmgo.dev](https://htmgo.dev/docs).
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=maddalax/htmgo&type=Date)](https://star-history.com/#maddalax/htmgo&Date)

View file

@ -11,3 +11,5 @@ require (
golang.org/x/sys v0.25.0
golang.org/x/tools v0.25.0
)
require github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect

View file

@ -1,3 +1,5 @@
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=

View file

@ -1,119 +0,0 @@
package main
import (
"bytes"
"fmt"
"log"
"strings"
"github.com/dave/jennifer/jen"
"golang.org/x/net/html"
)
func main() {
// Example HTML input
htmlData := `
<body><nav class="flex gap-4 items-center p-4 text-slate-600 "><a href="/" class="cursor-pointer hover:text-blue-400 ">Home</a><a class="cursor-pointer hover:text-blue-400 " href="/news">News</a><a href="/patients" class="cursor-pointer hover:text-blue-400 ">Patients</a></nav><div id="active-modal"></div><div class="flex flex-col gap-2 bg-white h-full "><div class="flex flex-col p-4 w-full "><div><div class="flex justify-between items-center "><p class="text-lg font-bold ">Manage Patients</p><button hx-target="#active-modal" type="button" id="add-patient" class="flex gap-1 items-center border p-4 rounded cursor-hover bg-blue-700 text-white rounded p-2 h-12 " hx-get="htmgo/partials/patient.AddPatientSheet">Add Patient</button></div><div hx-get="htmgo/partials/patient.List" hx-trigger="load, patient-added from:body" class=""><div class="mt-8" id="patient-list"><div class="flex flex-col gap-2 rounded p-4 bg-red-100 "><p>Name: Sydne</p><p>Reason for visit: arm hurts</p></div></div></div></div></div></div><div hx-get="/livereload" hx-trigger="every 200ms" class=""></div></body>
`
// Parse the HTML
doc, err := html.Parse(bytes.NewReader([]byte(htmlData)))
if err != nil {
log.Fatal(err)
}
// Create a new Jennifer file
f := jen.NewFile("main")
// Generate Jennifer code for the parsed HTML tree
generatedCode := processNode(doc.FirstChild)
// Add the generated code to the file
f.Func().Id("Render").Params().Block(generatedCode...)
// Render the generated code
var buf bytes.Buffer
err = f.Render(&buf)
if err != nil {
log.Fatal(err)
}
//// Format the generated code
//formattedCode, err := format.Source(buf.Bytes())
//if err != nil {
// log.Fatal(err)
//}
// Output the formatted code
fmt.Println(string(buf.Bytes()))
}
// Recursively process the HTML nodes and generate Jennifer code
func processNode(n *html.Node) []jen.Code {
var code []jen.Code
// Only process element nodes
if n.Type == html.ElementNode {
// Create a dynamic method call based on the tag name
tagMethod := strings.Title(n.Data) // Capitalize the first letter of the tag
// Add dynamic method call for the tag (e.g., h.Div(), h.Button(), etc.)
code = append(code, jen.Id("h").Dot(tagMethod).Call(mergeArgs(n)...))
}
return code
}
// Merge attributes and children into a single slice for Call()
func mergeArgs(n *html.Node) []jen.Code {
// Process attributes
attrs := processAttributes(n.Attr)
// Process children
children := processChildren(n)
// Combine attributes and children into one slice
return append(attrs, children...)
}
// Process child nodes of a given HTML node
func processChildren(n *html.Node) []jen.Code {
var children []jen.Code
for c := n.FirstChild; c != nil; c = c.NextSibling {
children = append(children, processNode(c)...)
}
return children
}
func FormatFieldName(name string) string {
split := strings.Split(name, "_")
if strings.Contains(name, "-") {
split = strings.Split(name, "-")
}
parts := make([]string, 0)
for _, s := range split {
parts = append(parts, PascalCase(s))
}
return strings.Join(parts, "")
}
func PascalCase(s string) string {
if s == "" {
return s
}
// Convert the first rune (character) to uppercase and concatenate with the rest of the string
return strings.ToUpper(string(s[0])) + s[1:]
}
// Process the attributes of an HTML node and return Jennifer code
func processAttributes(attrs []html.Attribute) []jen.Code {
var args []jen.Code
for _, attr := range attrs {
// Dynamically handle all attributes
attrMethod := FormatFieldName(attr.Key) // E.g., convert "data-role" to "DataRole"
args = append(args, jen.Id("h").Dot(attrMethod).Call(jen.Lit(attr.Val)))
}
return args
}

View file

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/framework/config"
"io"
"log/slog"
"os"
@ -17,6 +18,10 @@ func HasFileFromRoot(file string) bool {
return err == nil
}
func GetConfig() *config.ProjectConfig {
return config.FromConfigFile(process.GetWorkingDir())
}
func CreateHtmgoDir() {
if !HasFileFromRoot("__htmgo") {
CreateDirFromRoot("__htmgo")
@ -71,7 +76,7 @@ func MoveFile(src, dst string) error {
if err != nil {
return fmt.Errorf("failed to copy file: %v", err)
}
// Disconnect the source file.
// Remove the source file.
err = os.Remove(src)
if err != nil {
return fmt.Errorf("failed to remove source file: %v", err)

View file

@ -0,0 +1,31 @@
package dirutil
import (
"fmt"
"github.com/bmatcuk/doublestar/v4"
)
func matchesAny(patterns []string, path string) bool {
for _, pattern := range patterns {
matched, err := doublestar.Match(pattern, path)
if err != nil {
fmt.Printf("Error matching pattern: %v\n", err)
return false
}
if matched {
return true
}
}
return false
}
func IsGlobExclude(path string, excludePatterns []string) bool {
return matchesAny(excludePatterns, path)
}
func IsGlobMatch(path string, patterns []string, excludePatterns []string) bool {
if matchesAny(excludePatterns, path) {
return false
}
return matchesAny(patterns, path)
}

View file

@ -9,6 +9,7 @@ import (
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
"github.com/maddalax/htmgo/cli/htmgo/tasks/downloadtemplate"
"github.com/maddalax/htmgo/cli/htmgo/tasks/formatter"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/cli/htmgo/tasks/reloader"
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
@ -19,10 +20,10 @@ import (
)
func main() {
done := RegisterSignals()
needsSignals := true
commandMap := make(map[string]*flag.FlagSet)
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate"}
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate", "format"}
for _, command := range commands {
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
@ -56,6 +57,15 @@ func main() {
slog.Debug("Running task:", slog.String("task", taskName))
slog.Debug("working dir:", slog.String("dir", process.GetWorkingDir()))
if taskName == "format" {
needsSignals = false
}
done := make(chan bool, 1)
if needsSignals {
done = RegisterSignals()
}
if taskName == "watch" {
fmt.Printf("Running in watch mode\n")
os.Setenv("ENV", "development")
@ -90,7 +100,18 @@ func main() {
}()
startWatcher(reloader.OnFileChange)
} else {
if taskName == "schema" {
if taskName == "format" {
if len(os.Args) < 3 {
fmt.Println(fmt.Sprintf("Usage: htmgo format <file>"))
os.Exit(1)
}
file := os.Args[2]
if file == "." {
formatter.FormatDir(process.GetWorkingDir())
} else {
formatter.FormatFile(os.Args[2])
}
} else if taskName == "schema" {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter entity name:")
text, _ := reader.ReadString('\n')
@ -106,8 +127,7 @@ func main() {
} else if taskName == "ast" {
_ = astgen.GenAst(process.ExitOnError)
} else if taskName == "run" {
_ = astgen.GenAst(process.ExitOnError)
_ = css.GenerateCss(process.ExitOnError)
run.MakeBuildable()
_ = run.Server(process.ExitOnError)
} else if taskName == "template" {
name := ""

View file

@ -2,7 +2,9 @@ package astgen
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/framework/h"
"go/ast"
"go/parser"
"go/token"
@ -24,6 +26,7 @@ type Partial struct {
FuncName string
Package string
Import string
Path string
}
const GeneratedDirName = "__htmgo"
@ -53,7 +56,7 @@ func sliceCommonPrefix(dir1, dir2 string) string {
slicedDir1 := strings.TrimPrefix(dir1, commonPrefix)
slicedDir2 := strings.TrimPrefix(dir2, commonPrefix)
// Disconnect leading slashes
// Remove leading slashes
slicedDir1 = strings.TrimPrefix(slicedDir1, string(filepath.Separator))
slicedDir2 = strings.TrimPrefix(slicedDir2, string(filepath.Separator))
@ -103,6 +106,7 @@ func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial
if selectorExpr.Sel.Name == "Partial" {
p := Partial{
Package: node.Name.Name,
Path: sliceCommonPrefix(cwd, path),
Import: sliceCommonPrefix(cwd, strings.ReplaceAll(filepath.Dir(path), `\`, `/`)),
FuncName: funcDecl.Name.Name,
}
@ -254,12 +258,18 @@ func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
}
func writePartialsFile() {
config := dirutil.GetConfig()
cwd := process.GetWorkingDir()
partialPath := filepath.Join(cwd, "partials")
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
return partial.FuncName != "GetPartialFromContext"
})
partials = h.Filter(partials, func(partial Partial) bool {
return !dirutil.IsGlobExclude(partial.Path, config.AutomaticPartialRoutingIgnore)
})
if err != nil {
fmt.Println(err)
return
@ -317,6 +327,7 @@ func formatRoute(path string) string {
}
func writePagesFile() {
config := dirutil.GetConfig()
builder := NewCodeBuilder(nil)
builder.AppendLine(GeneratedFileLine)
@ -326,6 +337,10 @@ func writePagesFile() {
pages, _ := findPublicFuncsReturningHPage("pages")
pages = h.Filter(pages, func(page Page) bool {
return !dirutil.IsGlobExclude(page.Path, config.AutomaticPageRoutingIgnore)
})
if len(pages) > 0 {
builder.AddImport(ModuleName)
}

View file

@ -68,10 +68,10 @@ func (om *OrderedMap[K, V]) Values() []V {
// Delete removes a key-value pair from the OrderedMap.
func (om *OrderedMap[K, V]) Delete(key K) {
if _, exists := om.values[key]; exists {
// Disconnect the key from the map
// Remove the key from the map
delete(om.values, key)
// Disconnect the key from the keys slice
// 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:]...)

View file

@ -92,7 +92,7 @@ func CopyAssets() {
})
}
if !dirutil.HasFileFromRoot("tailwind.config.js") {
if dirutil.GetConfig().Tailwind && !dirutil.HasFileFromRoot("tailwind.config.js") {
err = dirutil.CopyFile(
filepath.Join(assetCssDir, "tailwind.config.js"),
filepath.Join(process.GetWorkingDir(), "tailwind.config.js"),

View file

@ -12,7 +12,7 @@ import (
)
func IsTailwindEnabled() bool {
return dirutil.HasFileFromRoot("tailwind.config.js")
return dirutil.GetConfig().Tailwind && dirutil.HasFileFromRoot("tailwind.config.js")
}
func Setup() bool {

View file

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

View file

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

View file

@ -108,6 +108,11 @@ func OnFileChange(version string, events []*fsnotify.Event) {
//tasks.Run = true
}
// something in public folder changed
if c.HasAnyPrefix("assets/public/") {
copyassets.CopyAssets()
}
if hasTask {
slog.Info("file changed", slog.String("version", version), slog.String("file", c.Name()))
}

View file

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

View file

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

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 {
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if filter(path) {
if filter(filepath.Base(path)) {
_ = ReplaceTextInFile(path, text, replacement)
}
return nil

View file

@ -4,6 +4,7 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/google/uuid"
"github.com/maddalax/htmgo/cli/htmgo/internal"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/module"
"log"
"log/slog"
@ -13,11 +14,10 @@ import (
"time"
)
var ignoredDirs = []string{".git", ".idea", "node_modules", "vendor"}
func startWatcher(cb func(version string, file []*fsnotify.Event)) {
events := make([]*fsnotify.Event, 0)
debouncer := internal.NewDebouncer(500 * time.Millisecond)
config := dirutil.GetConfig()
defer func() {
if r := recover(); r != nil {
@ -38,8 +38,38 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
if !ok {
return
}
slog.Debug("event:", slog.String("name", event.Name), slog.String("op", event.Op.String()))
if event.Has(fsnotify.Remove) {
if dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) {
watcher.Remove(event.Name)
continue
}
}
if event.Has(fsnotify.Create) {
if dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) {
watcher.Add(event.Name)
continue
}
info, err := os.Stat(event.Name)
if err != nil {
slog.Error("Error getting file info:", slog.String("path", event.Name), slog.String("error", err.Error()))
continue
}
if info.IsDir() {
err = watcher.Add(event.Name)
if err != nil {
slog.Error("Error adding directory to watcher:", slog.String("path", event.Name), slog.String("error", err.Error()))
} else {
slog.Debug("Watching directory:", slog.String("path", event.Name))
}
}
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
if !dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) {
continue
}
events = append(events, &event)
debouncer.Do(func() {
seen := make(map[string]bool)
@ -54,6 +84,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
events = make([]*fsnotify.Event, 0)
})
}
case err, ok := <-watcher.Errors:
if !ok {
return
@ -79,11 +110,10 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
return err
}
// Ignore directories in the ignoredDirs list
for _, ignoredDir := range ignoredDirs {
if ignoredDir == info.Name() {
if dirutil.IsGlobExclude(path, config.WatchIgnore) {
return filepath.SkipDir
}
}
// Only watch directories
if info.IsDir() {
err = watcher.Add(path)
@ -95,6 +125,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
}
return nil
})
if err != nil {
log.Fatal(err)
}

View file

@ -14,7 +14,7 @@ RUN go mod download
COPY . .
# Build the Go binary for Linux
RUN CGO_ENABLED=0 GOPRIVATE=github.com/maddalax LOG_LEVEL=debug go run github.com/maddalax/htmgo/cli/htmgo@8b816e956692683337d9fff6416ccc31f5047b59 build
RUN CGO_ENABLED=0 GOPRIVATE=github.com/maddalax LOG_LEVEL=debug go run github.com/maddalax/htmgo/cli/htmgo@latest build
RUN CGO_ENABLED=1 GOOS=linux go build -tags prod -o ./dist -a -ldflags '-linkmode external -extldflags "-static"' .

View file

@ -2,7 +2,7 @@ package chat
import (
"chat/internal/db"
"chat/ws"
"chat/sse"
"context"
"fmt"
"github.com/maddalax/htmgo/framework/h"
@ -11,79 +11,86 @@ import (
)
type Manager struct {
socketManager *ws.SocketManager
socketManager *sse.SocketManager
queries *db.Queries
service *Service
}
func NewManager(locator *service.Locator) *Manager {
return &Manager{
socketManager: service.Get[ws.SocketManager](locator),
socketManager: service.Get[sse.SocketManager](locator),
queries: service.Get[db.Queries](locator),
service: NewService(locator),
}
}
func (m *Manager) StartListener() {
c := make(chan ws.SocketEvent)
c := make(chan sse.SocketEvent, 1)
m.socketManager.Listen(c)
for {
select {
case event := <-c:
switch event.Type {
case ws.ConnectedEvent:
case sse.ConnectedEvent:
m.OnConnected(event)
case ws.DisconnectedEvent:
case sse.DisconnectedEvent:
m.OnDisconnected(event)
case ws.MessageEvent:
case sse.MessageEvent:
m.onMessage(event)
default:
fmt.Printf("Unknown event type: %s\n", event.Type)
}
}
}
}
func (m *Manager) OnConnected(e ws.SocketEvent) {
func (m *Manager) dispatchConnectedUsers(roomId string, predicate func(conn sse.SocketConnection) bool) {
connectedUsers := make([]db.User, 0)
// backfill all existing clients to the connected client
m.socketManager.ForEachSocket(roomId, func(conn sse.SocketConnection) {
if !predicate(conn) {
return
}
user, err := m.queries.GetUserBySessionId(context.Background(), conn.Id)
if err != nil {
return
}
connectedUsers = append(connectedUsers, user)
})
m.socketManager.ForEachSocket(roomId, func(conn sse.SocketConnection) {
m.socketManager.SendText(conn.Id, h.Render(ConnectedUsers(connectedUsers, conn.Id)))
})
}
func (m *Manager) OnConnected(e sse.SocketEvent) {
room, _ := m.service.GetRoom(e.RoomId)
if room == nil {
m.socketManager.CloseWithError(e.Id, 1008, "invalid room")
m.socketManager.CloseWithMessage(e.Id, "invalid room")
return
}
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
if err != nil {
m.socketManager.CloseWithError(e.Id, 1008, "invalid user")
m.socketManager.CloseWithMessage(e.Id, "invalid user")
return
}
fmt.Printf("User %s connected to %s\n", user.Name, e.RoomId)
// backfill all existing clients to the connected client
m.socketManager.ForEachSocket(e.RoomId, func(conn ws.SocketConnection) {
user, err := m.queries.GetUserBySessionId(context.Background(), conn.Id)
if err != nil {
return
}
isMe := conn.Id == e.Id
fmt.Printf("Sending connected user %s to %s\n", user.Name, e.Id)
m.socketManager.SendText(e.Id, h.Render(ConnectedUsers(user.Name, isMe)))
m.dispatchConnectedUsers(e.RoomId, func(conn sse.SocketConnection) bool {
return true
})
// send the connected user to all existing clients
m.socketManager.BroadcastText(
e.RoomId,
h.Render(ConnectedUsers(user.Name, false)),
func(conn ws.SocketConnection) bool {
return conn.Id != e.Id
},
)
go m.backFill(e.Id, e.RoomId)
m.backFill(e.Id, e.RoomId)
}
func (m *Manager) OnDisconnected(e ws.SocketEvent) {
func (m *Manager) OnDisconnected(e sse.SocketEvent) {
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
if err != nil {
return
@ -93,7 +100,7 @@ func (m *Manager) OnDisconnected(e ws.SocketEvent) {
return
}
fmt.Printf("User %s disconnected from %s\n", user.Name, room.ID)
m.socketManager.BroadcastText(room.ID, h.Render(ConnectedUser(user.Name, true, false)), func(conn ws.SocketConnection) bool {
m.dispatchConnectedUsers(e.RoomId, func(conn sse.SocketConnection) bool {
return conn.Id != e.Id
})
}
@ -116,7 +123,7 @@ func (m *Manager) backFill(socketId string, roomId string) {
}
}
func (m *Manager) onMessage(e ws.SocketEvent) {
func (m *Manager) onMessage(e sse.SocketEvent) {
message := e.Payload["message"].(string)
if message == "" {
@ -140,7 +147,7 @@ func (m *Manager) onMessage(e ws.SocketEvent) {
m.socketManager.BroadcastText(
e.RoomId,
h.Render(MessageRow(saved)),
func(conn ws.SocketConnection) bool {
func(conn sse.SocketConnection) bool {
return true
},
)

View file

@ -1,6 +1,7 @@
package chat
import (
"chat/internal/db"
"fmt"
"github.com/maddalax/htmgo/framework/h"
"strings"
@ -10,39 +11,43 @@ import (
func MessageRow(message *Message) *h.Element {
return h.Div(
h.Attribute("hx-swap-oob", "beforeend"),
h.Class("flex flex-col gap-4 w-full break-words whitespace-normal"), // Ensure container breaks long words
h.Class("flex flex-col gap-4 w-full break-words whitespace-normal"),
// Ensure container breaks long words
h.Id("messages"),
h.Div(
h.Class("flex flex-col gap-1"),
h.Div(
h.Class("flex gap-2 items-center"),
h.Pf(message.UserName, h.Class("font-bold")),
h.Pf(
message.UserName,
h.Class("font-bold"),
),
h.Pf(message.CreatedAt.In(time.Local).Format("01/02 03:04 PM")),
),
h.Article(
h.Class("break-words whitespace-normal"), // Ensure message text wraps correctly
h.P(h.Text(message.Message)),
h.Class("break-words whitespace-normal"),
// Ensure message text wraps correctly
h.P(
h.Text(message.Message),
),
),
),
)
}
func ConnectedUsers(username string, isMe bool) *h.Element {
func ConnectedUsers(users []db.User, myId string) *h.Element {
return h.Ul(
h.Attribute("hx-swap", "none"),
h.Attribute("hx-swap-oob", "beforeend"),
h.Attribute("hx-swap-oob", "outerHTML"),
h.Id("connected-users"),
h.Class("flex flex-col"),
// This would be populated dynamically with connected users
ConnectedUser(username, false, isMe),
h.List(users, func(user db.User, index int) *h.Element {
return connectedUser(user.Name, user.SessionID == myId)
}),
)
}
func ConnectedUser(username string, remove bool, isMe bool) *h.Element {
func connectedUser(username string, isMe bool) *h.Element {
id := fmt.Sprintf("connected-user-%s", strings.ReplaceAll(username, "#", "-"))
if remove {
return h.Div(h.Id(id), h.Attribute("hx-swap-oob", "delete"))
}
return h.Li(
h.Id(id),
h.ClassX("truncate text-slate-700", h.ClassMap{

View file

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

View file

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

View file

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

View file

@ -3,10 +3,9 @@ module chat
go 1.23.0
require (
github.com/coder/websocket v1.8.12
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0
github.com/mattn/go-sqlite3 v1.14.23
github.com/puzpuzpuz/xsync/v3 v3.4.0
)

View file

@ -1,15 +1,11 @@
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v0.0.0-20241001184532-9a5b92987701 h1:0Zk282axc1kPiuspLNzK5BJV7cQ5h2kPZHe54dznhYY=
github.com/maddalax/htmgo/framework v0.0.0-20241001184532-9a5b92987701/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692 h1:NtLJ7GcD9hWvPYmombxC1SzVNgvnhLXWhZEQJZOstik=
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0 h1:K9Q5b7BmbpCPJFjrAHS8+wPdKDcZN9NMC3Fg51n5IaQ=
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View file

@ -0,0 +1,25 @@
package routine
import (
"fmt"
"time"
)
func DebugLongRunning(name string, f func()) {
now := time.Now()
done := make(chan struct{}, 1)
go func() {
ticker := time.NewTicker(time.Second * 5)
for {
select {
case <-done:
return
case <-ticker.C:
elapsed := time.Since(now).Milliseconds()
fmt.Printf("function %s has not finished after %dms\n", name, elapsed)
}
}
}()
f()
done <- struct{}{}
}

View file

@ -4,24 +4,35 @@ import (
"chat/__htmgo"
"chat/chat"
"chat/internal/db"
"chat/ws"
"chat/sse"
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
"runtime"
"time"
)
func main() {
locator := service.NewLocator()
service.Set[db.Queries](locator, service.Singleton, db.Provide)
service.Set[ws.SocketManager](locator, service.Singleton, func() *ws.SocketManager {
return ws.NewSocketManager()
service.Set[sse.SocketManager](locator, service.Singleton, func() *sse.SocketManager {
return sse.NewSocketManager()
})
chatManager := chat.NewManager(locator)
go chatManager.StartListener()
go func() {
for {
count := runtime.NumGoroutine()
fmt.Printf("goroutines: %d\n", count)
time.Sleep(10 * time.Second)
}
}()
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
@ -35,7 +46,7 @@ func main() {
http.FileServerFS(sub)
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
app.Router.Handle("/ws/chat/{id}", ws.Handle())
app.Router.Handle("/sse/chat/{id}", sse.Handle())
__htmgo.Register(app.Router)
},

View file

@ -2,6 +2,7 @@ package pages
import (
"chat/chat"
"chat/internal/db"
"chat/partials"
"fmt"
"github.com/go-chi/chi/v5"
@ -15,20 +16,14 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
return h.NewPage(
RootPage(
h.Div(
h.JoinExtensions(
h.TriggerChildren(),
h.HxExtension("ws"),
),
h.Attribute("sse-connect", fmt.Sprintf("/ws/chat/%s", roomId)),
h.Attribute("sse-connect", fmt.Sprintf("/sse/chat/%s", roomId)),
h.HxOnSseOpen(
js.ConsoleLog("Connected to chat room"),
),
h.HxOnSseClose(
h.HxOnSseError(
js.EvalJs(fmt.Sprintf(`
const reason = e.detail.event.reason
const reason = e.detail.event.data
if(['invalid room', 'no session', 'invalid user'].includes(reason)) {
window.location.href = '/?roomId=%s';
} else if(e.detail.event.code === 1011) {
@ -40,40 +35,32 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
}
`, roomId, roomId)),
),
h.Class("flex flex-row min-h-screen bg-neutral-100"),
// Adjusted flex properties for responsive layout
h.Class("flex flex-row h-screen bg-neutral-100 overflow-x-hidden"),
// Collapse Button for mobile
CollapseButton(),
// Sidebar for connected users
UserSidebar(),
h.Div(
h.Class("flex flex-col flex-grow bg-white rounded p-4"),
// Adjusted to fill height and width
h.Class("flex flex-col h-full w-full bg-white p-4 overflow-hidden"),
// Room name at the top, fixed
CachedRoomHeader(ctx),
// Padding to push chat content below the fixed room name
h.Div(h.Class("pt-[50px]")),
h.HxAfterSseMessage(
js.EvalJsOnSibling("#messages",
`element.scrollTop = element.scrollHeight;`),
),
// Chat Messages
h.Div(
h.Id("messages"),
h.Class("flex flex-col gap-4 overflow-auto grow w-full mb-4 max-w-[calc(100%-215px)]"),
// Adjusted flex properties and removed max-width
h.Class("flex flex-col gap-4 mb-4 overflow-auto flex-grow w-full pt-[50px]"),
),
// Chat Input at the bottom
h.Div(
h.Class("mt-auto"),
Form(),
),
),
),
),
)
}
@ -93,7 +80,10 @@ func roomNameHeader(ctx *h.RequestContext) *h.Element {
}
return h.Div(
h.Class("bg-neutral-700 text-white p-3 shadow-sm w-full fixed top-0 left-0 flex justify-center z-10"),
h.H2F(room.Name, h.Class("text-lg font-bold")),
h.H2F(
room.Name,
h.Class("text-lg font-bold"),
),
h.Div(
h.Class("absolute right-5 top-3 cursor-pointer"),
h.Text("Share"),
@ -108,10 +98,13 @@ func roomNameHeader(ctx *h.RequestContext) *h.Element {
func UserSidebar() *h.Element {
return h.Div(
h.Class("pt-[67px] min-w-48 w-48 bg-neutral-200 p-4 flex flex-col justify-between gap-3 rounded-l-lg"),
h.Class("sidebar h-full pt-[67px] min-w-48 w-48 bg-neutral-200 p-4 flex-col justify-between gap-3 rounded-l-lg hidden md:flex"),
h.Div(
h.H3F("Connected Users", h.Class("text-lg font-bold")),
chat.ConnectedUsers("", false),
h.H3F(
"Connected Users",
h.Class("text-lg font-bold"),
),
chat.ConnectedUsers(make([]db.User, 0), ""),
),
h.A(
h.Class("cursor-pointer"),
@ -121,8 +114,30 @@ func UserSidebar() *h.Element {
)
}
func CollapseButton() *h.Element {
return h.Div(
h.Class("fixed top-0 left-4 md:hidden z-50"),
// Always visible on mobile
h.Button(
h.Class("p-2 text-2xl bg-neutral-700 text-white rounded-md"),
// Styling the button
h.OnClick(
js.EvalJs(`
const sidebar = document.querySelector('.sidebar');
sidebar.classList.toggle('hidden');
sidebar.classList.toggle('flex');
`),
),
h.UnsafeRaw("&#9776;"),
// The icon for collapsing the sidebar
),
)
}
func MessageInput() *h.Element {
return h.Input("text",
return h.Input(
"text",
h.Id("message-input"),
h.Required(),
h.Class("p-4 rounded-md border border-slate-200 w-full focus:outline-none focus:ring focus:ring-slate-200"),

View file

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

View file

@ -8,6 +8,10 @@ func RootPage(children ...h.Ren) h.Ren {
extensions := h.BaseExtensions()
return h.Html(
h.HxExtension(extensions),
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Meta("title", "htmgo chat example"),
h.Meta("charset", "utf-8"),
h.Meta("author", "htmgo"),
h.Head(
h.Link("/public/main.css", "stylesheet"),
h.Script("/public/htmgo.js"),

View file

@ -2,14 +2,14 @@ package partials
import (
"chat/components"
"chat/ws"
"chat/sse"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
)
func SendMessage(ctx *h.RequestContext) *h.Partial {
locator := ctx.ServiceLocator()
socketManager := service.Get[ws.SocketManager](locator)
socketManager := service.Get[sse.SocketManager](locator)
sessionCookie, err := ctx.Request.Cookie("session_id")

View file

@ -0,0 +1,112 @@
package sse
import (
"fmt"
"github.com/go-chi/chi/v5"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"log/slog"
"net/http"
"sync"
"time"
)
func Handle() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Set the necessary headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*") // Optional for CORS
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
locator := cc.ServiceLocator()
manager := service.Get[SocketManager](locator)
sessionCookie, _ := r.Cookie("session_id")
sessionId := ""
if sessionCookie != nil {
sessionId = sessionCookie.Value
}
ctx := r.Context()
/*
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{}
wg.Add(1)
/*
* This goroutine is responsible for writing messages to the client
*/
go func() {
defer wg.Done()
defer manager.Disconnect(sessionId)
defer func() {
fmt.Printf("empting channels\n")
for len(writer) > 0 {
<-writer
}
for len(done) > 0 {
<-done
}
}()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-done:
fmt.Printf("closing connection: \n")
return
case <-ticker.C:
manager.Ping(sessionId)
case message := <-writer:
_, err := fmt.Fprintf(w, message)
if err != nil {
done <- true
} else {
flusher, ok := w.(http.Flusher)
if ok {
flusher.Flush()
}
}
}
}
}()
/**
* This goroutine is responsible for adding the client to the room
*/
wg.Add(1)
go func() {
defer wg.Done()
if sessionId == "" {
manager.writeCloseRaw(writer, "no session")
return
}
roomId := chi.URLParam(r, "id")
if roomId == "" {
slog.Error("invalid room", slog.String("room_id", roomId))
manager.writeCloseRaw(writer, "invalid room")
return
}
manager.Add(roomId, sessionId, writer, done)
}()
wg.Wait()
}
}

View file

@ -1,12 +1,15 @@
package ws
package sse
import (
"chat/internal/routine"
"fmt"
"github.com/puzpuzpuz/xsync/v3"
"net/http"
"time"
)
type EventType string
type WriterChan chan string
type DoneChan chan bool
const (
ConnectedEvent EventType = "connected"
@ -28,10 +31,9 @@ type CloseEvent struct {
type SocketConnection struct {
Id string
Writer http.ResponseWriter
RoomId string
Done chan CloseEvent
Flush chan bool
Done DoneChan
Writer WriterChan
}
type SocketManager struct {
@ -62,13 +64,29 @@ 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) {
fmt.Printf("dispatching event: %s\n", event.Type)
done := make(chan struct{}, 1)
go func() {
for {
select {
case <-done:
fmt.Printf("dispatched event: %s\n", event.Type)
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) {
@ -84,7 +102,7 @@ func (manager *SocketManager) OnMessage(id string, message map[string]any) {
})
}
func (manager *SocketManager) Add(roomId string, id string, writer http.ResponseWriter, done chan CloseEvent, flush chan bool) {
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] {
@ -96,7 +114,6 @@ func (manager *SocketManager) Add(roomId string, id string, writer http.Response
Writer: writer,
RoomId: roomId,
Done: done,
Flush: flush,
})
s, ok := sockets.Load(id)
@ -110,6 +127,8 @@ func (manager *SocketManager) Add(roomId string, id string, writer http.Response
RoomId: s.RoomId,
Payload: map[string]any{},
})
fmt.Printf("User %s connected to %s\n", id, roomId)
}
func (manager *SocketManager) OnClose(id string) {
@ -126,25 +145,20 @@ func (manager *SocketManager) OnClose(id string) {
manager.sockets.Delete(id)
}
func (manager *SocketManager) CloseWithError(id string, code int, message string) {
func (manager *SocketManager) CloseWithMessage(id string, message string) {
conn := manager.Get(id)
if conn != nil {
go manager.OnClose(id)
conn.Done <- CloseEvent{
Code: code,
Reason: message,
}
defer manager.OnClose(id)
manager.writeText(*conn, "error", message)
conn.Done <- true
}
}
func (manager *SocketManager) Disconnect(id string) {
conn := manager.Get(id)
if conn != nil {
go manager.OnClose(id)
conn.Done <- CloseEvent{
Code: -1,
Reason: "",
}
manager.OnClose(id)
conn.Done <- true
}
}
@ -168,20 +182,32 @@ func (manager *SocketManager) Ping(id string) {
}
}
func (manager *SocketManager) writeCloseRaw(writer WriterChan, message string) {
manager.writeTextRaw(writer, "close", message)
}
func (manager *SocketManager) writeTextRaw(writer WriterChan, event string, message string) {
routine.DebugLongRunning("writeTextRaw", func() {
timeout := 3 * time.Second
data := ""
if event != "" {
data = fmt.Sprintf("event: %s\ndata: %s\n\n", event, message)
} else {
data = fmt.Sprintf("data: %s\n\n", message)
}
select {
case writer <- data:
case <-time.After(timeout):
fmt.Printf("could not send %s to channel after %s\n", data, timeout)
}
})
}
func (manager *SocketManager) writeText(socket SocketConnection, event string, message string) {
if socket.Writer == nil {
return
}
var err error
if event != "" {
_, err = fmt.Fprintf(socket.Writer, "event: %s\ndata: %s\n\n", event, message)
} else {
_, err = fmt.Fprintf(socket.Writer, "data: %s\n\n", message)
}
if err != nil {
manager.CloseWithError(socket.Id, 1008, "failed to write message")
}
socket.Flush <- true
manager.writeTextRaw(socket.Writer, event, message)
}
func (manager *SocketManager) BroadcastText(roomId string, message string, predicate func(conn SocketConnection) bool) {

View file

@ -1,77 +0,0 @@
package ws
import (
"fmt"
"github.com/go-chi/chi/v5"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"log/slog"
"net/http"
"time"
)
func Handle() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
sessionCookie, _ := r.Cookie("session_id")
if sessionCookie == nil {
slog.Error("session cookie not found")
return
}
locator := cc.ServiceLocator()
manager := service.Get[SocketManager](locator)
sessionId := sessionCookie.Value
roomId := chi.URLParam(r, "id")
if roomId == "" {
slog.Error("invalid room", slog.String("room_id", roomId))
manager.CloseWithError(sessionId, 1008, "invalid room")
return
}
done := make(chan CloseEvent, 50)
flush := make(chan bool, 50)
manager.Add(roomId, sessionId, w, done, flush)
defer func() {
manager.Disconnect(sessionId)
}()
// Set the necessary headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*") // Optional for CORS
// Flush the headers immediately
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
manager.Ping(sessionId)
case <-flush:
if flusher != nil {
flusher.Flush()
}
case <-done: // Client closed the connection
fmt.Println("Client disconnected")
return
}
}
}
}

View file

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

6
examples/hackernews/.gitignore vendored Normal file
View file

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

View file

@ -0,0 +1,38 @@
# 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 ["./hackernews"]

View file

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

View file

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

View file

@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

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

View file

@ -0,0 +1,14 @@
package components
import "github.com/maddalax/htmgo/framework/h"
func Badge(text string, active bool, children ...h.Ren) *h.Element {
return h.Button(
h.Text(text),
h.ClassX("font-semibold px-3 py-1 rounded-full cursor-pointer h-[32px]", h.ClassMap{
"bg-rose-500 text-white": active,
"bg-neutral-300": !active,
}),
h.Children(children...),
)
}

View file

@ -0,0 +1,10 @@
module hackernews
go 1.23.0
require github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0
require (
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
)

View file

@ -0,0 +1,16 @@
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.2-0.20241025174132-df3edccd7fb0 h1:K9Q5b7BmbpCPJFjrAHS8+wPdKDcZN9NMC3Fg51n5IaQ=
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0/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

@ -0,0 +1,30 @@
package batch
import (
"sync"
)
func ParallelProcess[T any, Z any](items []T, concurrency int, cb func(item T) Z) []Z {
if len(items) == 0 {
return []Z{}
}
if len(items) == 1 {
return []Z{cb(items[0])}
}
results := make([]Z, len(items))
wg := sync.WaitGroup{}
sem := make(chan struct{}, concurrency)
for i, item := range items {
wg.Add(1)
sem <- struct{}{}
go func(item T) {
defer func() {
wg.Done()
<-sem
}()
results[i] = cb(item)
}(item)
}
wg.Wait()
return results
}

View file

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

View file

@ -0,0 +1,115 @@
package httpjson
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
var (
client *http.Client
once sync.Once // Consider allowing configuration parameters for the singleton
)
func getClient() *http.Client {
once.Do(func() {
client = &http.Client{
Timeout: 10 * time.Second,
}
})
return client
}
func Get[T any](url string) (*T, error) {
client := getClient()
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result T
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return &result, nil
}
func Post[T any](url string, data T) (*http.Response, error) {
client := getClient()
body, err := json.Marshal(data)
if err != nil {
return nil, err
}
resp, err := client.Post(url, "application/json", bytes.NewBuffer(body))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return resp, nil
}
func Patch[T any](url string, data T) error {
client := getClient()
body, err := json.Marshal(data)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}
func Delete(url string) error {
client := getClient()
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}

View file

@ -0,0 +1,146 @@
package news
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"hackernews/internal/batch"
"hackernews/internal/httpjson"
"hackernews/internal/timeformat"
"log/slog"
"strconv"
"time"
)
const baseUrl = "https://hacker-news.firebaseio.com/v0/"
func url(path string, qs *h.Qs) string {
return baseUrl + path + ".json?" + qs.ToString()
}
type Category struct {
Name string
Path string
}
var Categories = []Category{
{"Top Stories", "topstories"},
{"Best Stories", "beststories"},
{"New Stories", "newstories"},
}
type Comment struct {
By string `json:"by"`
Text string `json:"text"`
TimeRaw int64 `json:"time"`
Time time.Time `json:"-"`
Type string `json:"type"`
Kids []int `json:"kids"`
Parent int `json:"parent"`
Id int `json:"id"`
}
type Story struct {
Id int `json:"id"`
By string `json:"by"`
Text string `json:"text"`
Title string `json:"title"`
Type string `json:"type"`
Descendents int `json:"descendants"`
Score int `json:"score"`
Url string
TimeRaw int64 `json:"time"`
Time time.Time `json:"-"`
// comment ids
Kids []int
}
type GetTopStoriesRequest struct {
Limit int
Page int
}
func MustItemId(ctx *h.RequestContext) int {
raw := h.GetQueryParam(ctx, "item")
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0
}
return int(parsed)
}
func GetStories(category string, page int, limit int) []Story {
top, err := httpjson.Get[[]int](url(category, h.NewQs()))
if err != nil {
slog.Error("failed to load top stories", slog.String("err", err.Error()))
return make([]Story, 0)
}
ids := *top
start := page * limit
end := start + limit
if start > len(ids) {
return make([]Story, 0)
}
if end > len(ids) {
end = len(ids)
}
return batch.ParallelProcess[int, Story](
ids[start:end],
50,
func(id int) Story {
story, err := GetStory(id)
if err != nil {
slog.Error("failed to load story", slog.Int("id", id), slog.String("err", err.Error()))
return Story{}
}
return *story
},
)
}
func GetTopStories(page int, limit int) []Story {
return GetStories("topstories", page, limit)
}
func GetBestStories(page int, limit int) []Story {
return GetStories("beststories", page, limit)
}
func GetNewStories(page int, limit int) []Story {
return GetStories("newstories", page, limit)
}
func GetComments(ids []int) []Comment {
return batch.ParallelProcess(
ids,
50,
func(id int) Comment {
comment, err := GetComment(id)
if err != nil {
slog.Error("failed to load comment", slog.Int("id", id), slog.String("err", err.Error()))
return Comment{}
}
return *comment
},
)
}
func GetComment(id int) (*Comment, error) {
c, err := httpjson.Get[Comment](url(fmt.Sprintf("item/%d", id), h.NewQs()))
if err != nil {
return nil, err
}
c.Time = timeformat.ParseUnix(c.TimeRaw)
return c, nil
}
func GetStory(id int) (*Story, error) {
s, err := httpjson.Get[Story](url(fmt.Sprintf("item/%d", id), h.NewQs()))
if err != nil {
return nil, err
}
s.Time = timeformat.ParseUnix(s.TimeRaw)
return s, nil
}

View file

@ -0,0 +1,11 @@
package parse
import "strconv"
func MustParseInt(s string, fallback int) int {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return fallback
}
return int(v)
}

View file

@ -0,0 +1,13 @@
package internal
import "math/rand"
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandSeq(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}

View file

@ -0,0 +1,39 @@
package timeformat
import (
"fmt"
"time"
)
func ParseUnix(t int64) time.Time {
return time.UnixMilli(t * 1000)
}
func RelativeTime(t time.Time) string {
now := time.Now()
diff := now.Sub(t)
var pluralize = func(s string) string {
if s[0] == '1' {
return s[:len(s)-5] + " ago"
}
return s
}
switch {
case diff < time.Minute:
return "just now"
case diff < time.Hour:
return pluralize(fmt.Sprintf("%d minutes ago", int(diff.Minutes())))
case diff < time.Hour*24:
return pluralize(fmt.Sprintf("%d hours ago", int(diff.Hours())))
case diff < time.Hour*24*7:
return pluralize(fmt.Sprintf("%d days ago", int(diff.Hours()/24)))
case diff < time.Hour*24*30:
return pluralize(fmt.Sprintf("%d weeks ago", int(diff.Hours()/(24*7))))
case diff < time.Hour*24*365:
return pluralize(fmt.Sprintf("%d months ago", int(diff.Hours()/(24*30))))
default:
return pluralize(fmt.Sprintf("%d years ago", int(diff.Hours()/(24*365))))
}
}

View file

@ -0,0 +1,36 @@
package main
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"hackernews/__htmgo"
"io/fs"
"net/http"
)
func main() {
locator := service.NewLocator()
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
app.Router.Handle("/item", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
w.Header().Set("Location", fmt.Sprintf("/?item=%s", id))
w.WriteHeader(302)
}))
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
__htmgo.Register(app.Router)
},
})
}

View file

@ -0,0 +1,21 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
"hackernews/partials"
)
func IndexPage(ctx *h.RequestContext) *h.Page {
return h.NewPage(
RootPage(
h.Div(
h.Class("flex gap-2 min-h-screen"),
partials.StorySidebar(ctx),
h.Main(
h.Class("flex justify-left items-start p-6 w-full"),
partials.Story(ctx),
),
),
),
)
}

View file

@ -0,0 +1,41 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func RootPage(children ...h.Ren) h.Ren {
banner := h.A(
h.Class("bg-neutral-200 text-neutral-600 text-center p-2 flex items-center justify-center"),
h.Href("https://github.com/maddalax/htmgo"),
h.Attribute("target", "_blank"),
h.Text("Built with htmgo.dev"),
)
return h.Html(
h.HxExtensions(
h.BaseExtensions(),
),
h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Link("/public/favicon.ico", "icon"),
h.Link("/public/apple-touch-icon.png", "apple-touch-icon"),
h.Meta("title", "hackernews"),
h.Meta("charset", "utf-8"),
h.Meta("author", "htmgo"),
h.Meta("description", "hacker news reader, built with htmgo"),
h.Meta("og:title", "hacker news reader"),
h.Meta("og:url", "https://hn.htmgo.dev"),
h.Link("canonical", "https://hn.htmgo.dev"),
h.Meta("og:description", "hacker news reader, built with htmgo"),
h.Link("/public/main.css", "stylesheet"),
h.Script("/public/htmgo.js"),
),
h.Body(
banner,
h.Div(
h.Fragment(children...),
),
),
)
}

View file

@ -0,0 +1,102 @@
package partials
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"hackernews/internal/batch"
"hackernews/internal/news"
"hackernews/internal/timeformat"
"strings"
"time"
)
func StoryComments(ctx *h.RequestContext) *h.Partial {
return h.NewPartial(
h.Fragment(
h.OobSwap(
ctx,
h.Div(
h.Id("comments-loader"),
),
),
h.Div(
h.Class("flex flex-col gap-3 prose max-w-none"),
CachedStoryComments(news.MustItemId(ctx)),
),
),
)
}
var CachedStoryComments = h.CachedPerKeyT[string, int](time.Minute*3, func(itemId int) (string, h.GetElementFunc) {
return fmt.Sprintf("story-comments-%d", itemId), func() *h.Element {
story, err := news.GetStory(itemId)
if err != nil {
return h.Div(
h.Text("Failed to load story"),
)
}
comments := news.GetComments(story.Kids)
// parallel process because each comment needs to load its children comments
items := batch.ParallelProcess[news.Comment, *h.Element](comments, 50, func(item news.Comment) *h.Element {
return Comment(item, 0)
})
return h.List(items, func(item *h.Element, index int) *h.Element {
return item
})
}
})
func Comment(item news.Comment, nesting int) *h.Element {
if item.Text == "" {
return h.Empty()
}
children := news.GetComments(item.Kids)
return h.Div(
h.ClassX("block bg-white pb-2 pt-2", h.ClassMap{
"border-b border-gray-200": nesting == 0,
"border-l border-gray-200": nesting > 0,
}),
h.If(
nesting > 0,
h.Attribute("style", fmt.Sprintf("margin-left: %dpx", (nesting-1)*15)),
),
h.Div(
h.If(
nesting > 0,
h.Class("pl-4"),
),
h.Div(
h.Class("flex gap-1 items-center"),
h.Div(
h.Class("font-bold text-rose-500"),
h.UnsafeRaw(item.By),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.UnsafeRaw("&bull;"),
h.TextF(" %s", timeformat.RelativeTime(item.Time)),
),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.UnsafeRaw(strings.TrimSpace(item.Text)),
),
),
h.If(
len(children) > 0,
h.List(
children, func(child news.Comment, index int) *h.Element {
return h.Div(
Comment(child, nesting+1),
)
},
),
),
)
}

View file

@ -0,0 +1,164 @@
package partials
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"hackernews/components"
"hackernews/internal/news"
"hackernews/internal/parse"
"hackernews/internal/timeformat"
"time"
)
var ScrollJs = `
const scrollContainer = self;
let isDown = false;
let startX;
let scrollLeft;
scrollContainer.addEventListener("mousedown", (e) => {
isDown = true;
scrollContainer.classList.add("active");
startX = e.pageX - scrollContainer.offsetLeft;
scrollLeft = scrollContainer.scrollLeft;
});
scrollContainer.addEventListener("mouseleave", () => {
isDown = false;
scrollContainer.classList.remove("active");
});
scrollContainer.addEventListener("mouseup", () => {
isDown = false;
scrollContainer.classList.remove("active");
});
scrollContainer.addEventListener("mousemove", (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - scrollContainer.offsetLeft;
const walk = (x - startX) * 3; // Adjust scroll speed here
scrollContainer.scrollLeft = scrollLeft - walk;
});
`
func StorySidebar(ctx *h.RequestContext) *h.Partial {
category := h.GetQueryParam(ctx, "category")
pageRaw := h.GetQueryParam(ctx, "page")
mode := h.GetQueryParam(ctx, "mode")
if pageRaw == "" {
pageRaw = "0"
}
if category == "" {
category = "topstories"
}
page := parse.MustParseInt(pageRaw, 0)
fetchMorePath := h.GetPartialPathWithQs(
StorySidebar,
h.NewQs("mode", "infinite", "page", fmt.Sprintf("%d", page+1), "category", category),
)
list := CachedStoryList(category, page, 50, fetchMorePath)
body := h.Aside(
h.Id("story-sidebar"),
h.JoinExtensions(
h.TriggerChildren(),
),
h.Class("sticky top-0 h-screen p-1 bg-gray-100 overflow-y-auto max-w-80 min-w-80"),
h.Div(
h.Class("flex flex-col gap-1"),
SidebarTitle(category),
h.Id("story-list"),
list,
),
)
if mode == "infinite" {
return h.NewPartial(
list,
)
}
if ctx.IsHxRequest() {
return h.SwapManyPartial(ctx, body)
}
return h.NewPartial(body)
}
func SidebarTitle(defaultCategory string) *h.Element {
today := time.Now().Format("Mon, 02 Jan 2006")
return h.Div(
h.Class("flex flex-col px-2 pt-4 pb-2"),
h.Div(
h.Class("text-sm text-gray-600"),
h.Text(today),
),
h.Div(
h.Class("font-bold text-xl"),
h.Text("Hacker News"),
),
h.Div(
h.OnLoad(
h.EvalJs(ScrollJs),
),
h.Class("scroll-container mt-2 flex gap-1 no-scrollbar overflow-y-hidden whitespace-nowrap overflow-x-auto"),
h.List(news.Categories, func(item news.Category, index int) *h.Element {
return CategoryBadge(defaultCategory, item)
}),
),
)
}
func CategoryBadge(defaultCategory string, category news.Category) *h.Element {
selected := category.Path == defaultCategory
return components.Badge(
category.Name,
selected,
h.Attribute("hx-swap", "none"),
h.If(
!selected,
h.PostPartialOnClickQs(
StorySidebar,
h.NewQs("category", category.Path),
),
),
)
}
var CachedStoryList = h.CachedPerKeyT4(time.Minute*5, func(category string, page int, limit int, fetchMorePath string) (string, h.GetElementFunc) {
return fmt.Sprintf("%s-stories-%d-%d", category, page, limit), func() *h.Element {
stories := news.GetStories(category, page, limit)
return h.List(stories, func(item news.Story, index int) *h.Element {
return h.Div(
h.Attribute("hx-swap", "none"),
h.PostPartialOnClickQs(Story, h.NewQs("item", fmt.Sprintf("%d", item.Id))),
h.A(h.Href(item.Url)),
h.Class("block p-2 bg-white rounded-md shadow cursor-pointer"),
h.Div(
h.Class("font-bold"),
h.UnsafeRaw(item.Title),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.Div(h.TextF("%s ", item.By), h.UnsafeRaw("&bull;"), h.TextF(" %s", timeformat.RelativeTime(item.Time))),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.UnsafeRaw(fmt.Sprintf("%d upvotes &bull; %d comments", item.Score, item.Descendents)),
),
h.If(index == len(stories)-1, h.Div(
h.Id("load-more"),
h.Attribute("hx-swap", "beforeend"),
h.HxTarget("#story-list"),
h.Get(fetchMorePath, "intersect once"),
)),
)
})
}
})

View file

@ -0,0 +1,92 @@
package partials
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"hackernews/internal/news"
"hackernews/internal/timeformat"
"time"
)
func Story(ctx *h.RequestContext) *h.Partial {
storyId := news.MustItemId(ctx)
if storyId == 0 {
return h.NewPartial(
h.Div(
h.Class("flex justify-center bg-neutral-300"),
h.Id("story-body"),
),
)
}
if ctx.IsHxRequest() {
return h.SwapManyPartialWithHeaders(
ctx,
h.PushUrlHeader(fmt.Sprintf("/?item=%d", storyId)),
h.Div(
h.Class("w-full"),
h.Id("story-body"),
CachedStoryBody(storyId),
),
)
}
return h.NewPartial(
CachedStoryBody(storyId),
)
}
var CachedStoryBody = h.CachedPerKeyT[string, int](time.Minute*3, func(itemId int) (string, h.GetElementFunc) {
return fmt.Sprintf("story-%d", itemId), func() *h.Element {
story, err := news.GetStory(itemId)
if err != nil {
return h.Div(
h.Id("story-body"),
h.Text("Failed to load story"),
)
}
return StoryBody(story)
}
})
func StoryBody(story *news.Story) *h.Element {
return h.Div(
h.Class("w-full"),
h.Id("story-body"),
h.Div(
h.Class("prose prose-2xl border-b border-gray-200 pb-3 max-w-none w-full"),
h.H5(
h.Class("flex gap-2 items-left font-bold"),
h.UnsafeRaw(story.Title),
),
h.A(
h.Href(story.Url),
h.Class("text-sm text-rose-400 no-underline"),
h.Text(story.Url),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.UnsafeRaw(story.Text),
),
h.Div(
h.Class("text-sm text-gray-600 mt-2"),
h.TextF("%d upvotes ", story.Score),
h.UnsafeRaw("&bull;"),
h.TextF(" %s ", story.By),
h.UnsafeRaw("&bull;"),
h.TextF(" %s", timeformat.RelativeTime(story.Time)),
),
),
h.Div(
h.Id("comments-loader"),
h.Class("flex justify-center items-center h-24"),
h.Div(
h.Class("animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-rose-500"),
),
),
h.Div(
h.Class("mt-2 min-w-3xl max-w-3xl"),
h.GetPartial(StoryComments, "load"),
),
)
}

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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