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

# Conflicts:
#	examples/hackernews/main.go
#	examples/sse-with-state/event/listener.go
#	examples/sse-with-state/pages/index.go
#	examples/sse-with-state/pages/root.go
#	examples/sse-with-state/partials/click.go
#	examples/sse-with-state/partials/index.go
#	examples/sse-with-state/partials/repeater.go
#	examples/sse-with-state/sse/handler.go
#	examples/sse-with-state/sse/manager.go
#	examples/sse-with-state/state/state.go
#	framework/assets/dist/htmgo.js
#	framework/assets/js/htmxextensions/ws-event-handler.ts
#	framework/assets/js/htmxextensions/ws.ts
This commit is contained in:
maddalax 2024-10-15 13:35:49 -05:00
commit 5857a795f5
141 changed files with 2349 additions and 453 deletions

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

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

View file

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

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/**"
@ -46,4 +49,4 @@ jobs:
- name: Push Docker image
run: |
docker push ghcr.io/${{ github.repository_owner }}/htmgo-site:latest
docker push ghcr.io/${{ github.repository_owner }}/htmgo-site:latest

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:
@ -43,4 +47,4 @@ jobs:
- name: Push Docker image
run: |
docker push ghcr.io/${{ github.repository_owner }}/starter-template:latest
docker push ghcr.io/${{ github.repository_owner }}/starter-template:latest

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:
@ -43,4 +47,4 @@ jobs:
- name: Push Docker image
run: |
docker push ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:latest
docker push ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:latest

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,5 +1,5 @@
> [!WARNING]
> htmgo is in alpha release and active development. API's likely will have breaking changes. Do not use this library at this time if you are expecting a rock solid stable api that will require no migrations. Please report any issues on GitHub.
> htmgo is in alpha release and active development. API's may have breaking changes between versions. Please report any issues on GitHub.
## **htmgo**
@ -40,3 +40,7 @@ func IndexPage(ctx *h.RequestContext) *h.Page {
**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")

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

@ -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

@ -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() {
return filepath.SkipDir
}
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

@ -5,7 +5,7 @@ go 1.23.0
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d
github.com/mattn/go-sqlite3 v1.14.23
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/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-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g=
github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/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

@ -35,4 +35,4 @@ EXPOSE 3000
# Command to run the binary
CMD ["./sse-with-state"]
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

@ -4,8 +4,8 @@
package main
import (
"hackernews/internal/embedded"
"io/fs"
"sse-with-state/internal/embedded"
)
func GetStaticAssets() fs.FS {

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,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 v0.0.0-20241014151703-8503dffa4e7d
require (
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
)

View file

@ -2,23 +2,15 @@ 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 v0.0.0-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g=
github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/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=
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

@ -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,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,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,38 @@
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,88 @@
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,151 @@
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

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

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,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -1,14 +0,0 @@
module sse-with-state
go 1.23.0
require github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b
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
golang.org/x/sys v0.6.0 // indirect
)

View file

@ -1,47 +0,0 @@
package main
import (
"encoding/json"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
"sse-with-state/__htmgo"
"sse-with-state/event"
"sse-with-state/sse"
)
func main() {
locator := service.NewLocator()
service.Set[sse.SocketManager](locator, service.Singleton, func() *sse.SocketManager {
return sse.NewSocketManager()
})
event.StartListener(locator)
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
app.Router.Handle("/ws/test", sse.HandleWs())
app.Router.Get("/metrics", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
metrics := event.GetMetrics()
serialized, _ := json.Marshal(metrics)
_, _ = writer.Write(serialized)
})
__htmgo.Register(app.Router)
},
})
}

View file

@ -5,7 +5,7 @@ go 1.23.0
require (
entgo.io/ent v0.14.1
github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g=
github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=

View file

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

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/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-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g=
github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/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=

View file

@ -6,8 +6,7 @@ import "./htmxextensions/response-targets";
import "./htmxextensions/mutation-error";
import "./htmxextensions/livereload"
import "./htmxextensions/htmgo";
import "./htmxextensions/ws"
import "./htmxextensions/ws-event-handler"
import "./htmxextensions/sse"
// @ts-ignore
window.htmx = htmx;
@ -19,7 +18,7 @@ function watchUrl(callback: (oldUrl: string, newUrl: string) => void) {
callback(lastUrl, window.location.href);
lastUrl = window.location.href;
}
}, 100);
}, 101);
}
watchUrl((_, newUrl) => {

View file

@ -8,9 +8,30 @@ htmx.defineExtension("htmgo", {
if(name === "htmx:beforeCleanupElement" && evt.target) {
removeAssociatedScripts(evt.target as HTMLElement);
}
if(name === "htmx:load" && evt.target) {
invokeOnLoad(evt.target as HTMLElement);
}
},
});
/**
* Browser doesn't support onload for all elements, so we need to manually trigger it
* this is useful for locality of behavior
*/
function invokeOnLoad(element : Element) {
if(element == null || !(element instanceof HTMLElement)) {
return
}
const ignored = ['SCRIPT', 'LINK', 'STYLE', 'META', 'BASE', 'TITLE', 'HEAD', 'HTML', 'BODY'];
if(!ignored.includes(element.tagName)) {
if(element.hasAttribute("onload")) {
element.onload!(new Event("load"));
}
}
// check its children
element.querySelectorAll('[onload]').forEach(invokeOnLoad)
}
export function removeAssociatedScripts(element: HTMLElement) {
const attributes = Array.from(element.attributes)
for (let attribute of attributes) {

View file

@ -0,0 +1,72 @@
import htmx from 'htmx.org'
import {removeAssociatedScripts} from "./htmgo";
let api : any = null;
let processed = new Set<string>()
htmx.defineExtension("sse", {
init: function (apiRef) {
api = apiRef;
},
// @ts-ignore
onEvent: function (name, evt) {
const target = evt.target;
if(!(target instanceof HTMLElement)) {
return
}
if(name === 'htmx:beforeCleanupElement') {
removeAssociatedScripts(target);
}
if(name === 'htmx:beforeProcessNode') {
const elements = document.querySelectorAll('[sse-connect]');
for (let element of Array.from(elements)) {
const url = element.getAttribute("sse-connect")!;
if(url && !processed.has(url)) {
connectEventSource(element, url)
processed.add(url)
}
}
}
}
})
function connectEventSource(ele: Element, url: string) {
if(!url) {
return
}
console.info('Connecting to EventSource', url)
const eventSource = new EventSource(url);
eventSource.addEventListener("close", function(event) {
htmx.trigger(ele, "htmx:sseClose", {event: event});
})
eventSource.onopen = function(event) {
htmx.trigger(ele, "htmx:sseOpen", {event: event});
}
eventSource.onerror = function(event) {
htmx.trigger(ele, "htmx:sseError", {event: event});
if (eventSource.readyState == EventSource.CLOSED) {
htmx.trigger(ele, "htmx:sseClose", {event: event});
}
}
eventSource.onmessage = function(event) {
const settleInfo = api.makeSettleInfo(ele);
htmx.trigger(ele, "htmx:sseBeforeMessage", {event: event});
const response = event.data
const fragment = api.makeFragment(response) as DocumentFragment;
const children = Array.from(fragment.children);
for (let child of children) {
api.oobSwap(api.getAttributeValue(child, 'hx-swap-oob') || 'true', child, settleInfo);
// support htmgo eval__ scripts
if(child.tagName === 'SCRIPT' && child.id.startsWith("__eval")) {
document.body.appendChild(child);
}
}
htmx.trigger(ele, "htmx:sseAfterMessage", {event: event});
}
}

View file

@ -4,7 +4,7 @@ function kebabEventName(str: string) {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
}
const ignoredEvents = ['htmx:beforeProcessNode', 'htmx:afterProcessNode', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:beforeOnLoad', 'htmx:afterOnLoad', 'htmx:configRequest', 'htmx:configResponse', 'htmx:responseError'];
const ignoredEvents = ['htmx:beforeProcessNode', 'htmx:afterProcessNode', 'htmx:configRequest', 'htmx:configResponse', 'htmx:responseError'];
function makeEvent(eventName: string, detail: any) {
let evt
@ -28,13 +28,15 @@ function triggerChildren(target: HTMLElement, name: string, event: CustomEvent,
const eventName = kehab.replace("htmx:", "hx-on::")
if (!triggered.has(e as HTMLElement)) {
if(e.hasAttribute(eventName)) {
const newEvent = makeEvent(eventName.replace("hx-on::", "htmx:"), {
...event.detail,
target: e,
})
newEvent.detail.meta = 'trigger-children'
e.dispatchEvent(newEvent)
triggered.add(e as HTMLElement);
setTimeout(() => {
const newEvent = makeEvent(eventName.replace("hx-on::", "htmx:"), {
...event.detail,
target: e,
})
newEvent.detail.meta = 'trigger-children'
e.dispatchEvent(newEvent)
triggered.add(e as HTMLElement);
}, 1)
}
if (e.children) {
triggerChildren(e as HTMLElement, name, event, triggered);

View file

@ -0,0 +1,58 @@
package config
import (
"gopkg.in/yaml.v3"
"log/slog"
"os"
"path"
)
type ProjectConfig struct {
Tailwind bool `yaml:"tailwind"`
WatchIgnore []string `yaml:"watch_ignore"`
WatchFiles []string `yaml:"watch_files"`
}
func DefaultProjectConfig() *ProjectConfig {
return &ProjectConfig{
Tailwind: true,
WatchIgnore: []string{
"node_modules", ".git", ".idea", "assets/dist",
},
WatchFiles: []string{
"**/*.go", "**/*.html", "**/*.css", "**/*.js", "**/*.json", "**/*.yaml", "**/*.yml", "**/*.md",
},
}
}
func (cfg *ProjectConfig) EnhanceWithDefaults() *ProjectConfig {
defaultCfg := DefaultProjectConfig()
if len(cfg.WatchFiles) == 0 {
cfg.WatchFiles = defaultCfg.WatchFiles
}
if len(cfg.WatchIgnore) == 0 {
cfg.WatchIgnore = defaultCfg.WatchIgnore
}
return cfg
}
func FromConfigFile(workingDir string) *ProjectConfig {
defaultCfg := DefaultProjectConfig()
names := []string{"htmgo.yaml", "htmgo.yml", "_htmgo.yaml", "_htmgo.yml"}
for _, name := range names {
filePath := path.Join(workingDir, name)
if _, err := os.Stat(filePath); err == nil {
cfg := &ProjectConfig{}
bytes, err := os.ReadFile(filePath)
if err == nil {
err = yaml.Unmarshal(bytes, cfg)
if err != nil {
slog.Error("Error parsing config file", slog.String("file", filePath), slog.String("error", err.Error()))
os.Exit(1)
}
return cfg.EnhanceWithDefaults()
}
}
}
return defaultCfg
}

View file

@ -0,0 +1,50 @@
package config
import (
"github.com/stretchr/testify/assert"
"os"
"path"
"testing"
)
func TestDefaultProjectConfig(t *testing.T) {
t.Parallel()
cfg := DefaultProjectConfig()
assert.Equal(t, true, cfg.Tailwind)
assert.Equal(t, 4, len(cfg.WatchIgnore))
assert.Equal(t, 8, len(cfg.WatchFiles))
}
func TestNoConfigFileUsesDefault(t *testing.T) {
t.Parallel()
cfg := FromConfigFile("non-existing-dir")
assert.Equal(t, true, cfg.Tailwind)
assert.Equal(t, 4, len(cfg.WatchIgnore))
assert.Equal(t, 8, len(cfg.WatchFiles))
}
func TestPartialConfigMerges(t *testing.T) {
t.Parallel()
dir := writeConfigFile(t, "tailwind: false")
cfg := FromConfigFile(dir)
assert.Equal(t, false, cfg.Tailwind)
assert.Equal(t, 4, len(cfg.WatchIgnore))
assert.Equal(t, 8, len(cfg.WatchFiles))
}
func TestShouldNotSetTailwindTrue(t *testing.T) {
t.Parallel()
dir := writeConfigFile(t, "someValue: true")
cfg := FromConfigFile(dir)
assert.Equal(t, false, cfg.Tailwind)
assert.Equal(t, 4, len(cfg.WatchIgnore))
assert.Equal(t, 8, len(cfg.WatchFiles))
}
func writeConfigFile(t *testing.T, content string) string {
temp := os.TempDir()
os.Mkdir(temp, 0755)
err := os.WriteFile(path.Join(temp, "htmgo.yml"), []byte(content), 0644)
assert.Nil(t, err)
return temp
}

View file

@ -7,10 +7,10 @@ require (
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.9.0
golang.org/x/net v0.29.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -20,7 +20,7 @@ type RequestContext struct {
Response http.ResponseWriter
locator *service.Locator
isBoosted bool
CurrentBrowserUrl string
currentBrowserUrl string
hxPromptResponse string
isHxRequest bool
hxTargetId string
@ -29,14 +29,54 @@ type RequestContext struct {
kv map[string]interface{}
}
func GetRequestContext(r *http.Request) *RequestContext {
return r.Context().Value(RequestContextKey).(*RequestContext)
}
func (c *RequestContext) FormValue(key string) string {
return c.Request.FormValue(key)
}
func (c *RequestContext) Header(key string) string {
return c.Request.Header.Get(key)
}
func (c *RequestContext) UrlParam(key string) string {
return chi.URLParam(c.Request, key)
}
func (c *RequestContext) QueryParam(key string) string {
return c.Request.URL.Query().Get(key)
}
func (c *RequestContext) IsBoosted() bool {
return c.isBoosted
}
func (c *RequestContext) IsHxRequest() bool {
return c.isHxRequest
}
func (c *RequestContext) HxPromptResponse() string {
return c.hxPromptResponse
}
func (c *RequestContext) HxTargetId() string {
return c.hxTargetId
}
func (c *RequestContext) HxTriggerName() string {
return c.hxTriggerName
}
func (c *RequestContext) HxTriggerId() string {
return c.hxTriggerId
}
func (c *RequestContext) HxCurrentBrowserUrl() string {
return c.currentBrowserUrl
}
func (c *RequestContext) Set(key string, value interface{}) {
if c.kv == nil {
c.kv = make(map[string]interface{})
@ -79,8 +119,7 @@ const RequestContextKey = "htmgo.request.context"
func populateHxFields(cc *RequestContext) {
cc.isBoosted = cc.Request.Header.Get(hx.BoostedHeader) == "true"
cc.isBoosted = cc.Request.Header.Get(hx.BoostedHeader) == "true"
cc.CurrentBrowserUrl = cc.Request.Header.Get(hx.CurrentUrlHeader)
cc.currentBrowserUrl = cc.Request.Header.Get(hx.CurrentUrlHeader)
cc.hxPromptResponse = cc.Request.Header.Get(hx.PromptResponseHeader)
cc.isHxRequest = cc.Request.Header.Get(hx.RequestHeader) == "true"
cc.hxTargetId = cc.Request.Header.Get(hx.TargetIdHeader)

View file

@ -1,7 +1,6 @@
package h
import (
"html"
"net/http"
"reflect"
"runtime"
@ -85,9 +84,9 @@ func SwapManyXPartial(ctx *RequestContext, swaps ...SwapArg) *Partial {
}
func GetPartialPath(partial PartialFunc) string {
return runtime.FuncForPC(reflect.ValueOf(partial).Pointer()).Name()
return "/" + runtime.FuncForPC(reflect.ValueOf(partial).Pointer()).Name()
}
func GetPartialPathWithQs(partial func(ctx *RequestContext) *Partial, qs *Qs) string {
return html.EscapeString(GetPartialPath(partial) + "?" + qs.ToString())
return GetPartialPath(partial) + "?" + qs.ToString()
}

View file

@ -3,7 +3,7 @@ package h
import "strings"
func BaseExtensions() string {
extensions := []string{"path-deps", "response-targets", "mutation-error", "htmgo", "sse", "ws"}
extensions := []string{"path-deps", "response-targets", "mutation-error", "htmgo", "sse"}
if IsDevelopment() {
extensions = append(extensions, "livereload")
}

View file

@ -16,7 +16,7 @@ func PushUrlHeader(url string) *Headers {
}
func PushQsHeader(ctx *RequestContext, qs *Qs) *Headers {
parsed, err := url.Parse(ctx.CurrentBrowserUrl)
parsed, err := url.Parse(ctx.currentBrowserUrl)
if err != nil {
return NewHeaders()
}

View file

@ -51,6 +51,7 @@ func (l *LifeCycle) OnEvent(event hx.Event, cmd ...Command) *LifeCycle {
return l
}
// OnLoad This will work on any element because of the htmgo htmx extension to trigger it, instead of the browser.
func OnLoad(cmd ...Command) *LifeCycle {
return NewLifeCycle().OnEvent(hx.LoadDomEvent, cmd...)
}

View file

@ -51,7 +51,7 @@ func (q *Qs) ToString() string {
func GetQueryParam(ctx *RequestContext, key string) string {
value, ok := ctx.Request.URL.Query()[key]
if value == nil || !ok {
current := ctx.CurrentBrowserUrl
current := ctx.currentBrowserUrl
if current != "" {
u, err := url.Parse(current)
if err == nil {

View file

@ -2,7 +2,6 @@ package h
import (
"fmt"
"github.com/maddalax/htmgo/framework/internal/util"
"strconv"
)
@ -154,10 +153,6 @@ func Div(children ...Ren) *Element {
return Tag("div", children...)
}
func GenId() string {
return util.RandSeq(6)
}
func Article(children ...Ren) *Element {
return Tag("article", children...)
}
@ -187,6 +182,10 @@ func Input(inputType string, children ...Ren) *Element {
}
}
func TextArea(children ...Ren) *Element {
return Tag("textarea", children...)
}
func TextInput(children ...Ren) *Element {
return Input("text", children...)
}
@ -467,6 +466,10 @@ func THead(children ...Ren) *Element {
return Tag("thead", children...)
}
func I(children ...Ren) *Element {
return Tag("i", children...)
}
func TFoot(children ...Ren) *Element {
return Tag("tfoot", children...)
}

View file

@ -86,7 +86,6 @@ const (
HistoryCacheMissLoadEvent Event = "htmx:historyCacheMissLoad"
HistoryRestoreEvent Event = "htmx:historyRestore"
BeforeHistorySaveEvent Event = "htmx:beforeHistorySave"
LoadEvent Event = "htmx:load"
NoSSESourceErrorEvent Event = "htmx:noSSESourceError"
OnLoadErrorEvent Event = "htmx:onLoadError"
OobAfterSwapEvent Event = "htmx:oobAfterSwap"
@ -131,6 +130,7 @@ const (
KeyPressEvent Event = "onkeypress"
SubmitEvent Event = "onsubmit"
LoadDomEvent Event = "onload"
LoadEvent Event = "onload"
UnloadEvent Event = "onunload"
ResizeEvent Event = "onresize"
ScrollEvent Event = "onscroll"

View file

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

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: 908 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View file

@ -3,14 +3,17 @@ module htmgo-site
go 1.23.0
require (
github.com/alecthomas/chroma/v2 v2.14.0
github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241011161932-8b9e536f1490
github.com/yuin/goldmark v1.7.4
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
)
require (
github.com/alecthomas/chroma/v2 v2.2.0 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/text v0.19.0 // indirect
)

View file

@ -1,19 +1,28 @@
github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae h1:zzGwJfFlFGD94CyyYwCJeSuD32Gj9GTaSi5y9hoVzdY=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g=
github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns=
github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241011161932-8b9e536f1490 h1:D7jkugRnEtKACr4kQH6eSNxB8cKXgrhLm+5yeLsvscg=
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241011161932-8b9e536f1490/go.mod h1:hpDNkFnNT0FIgmQsVjMeQOzLuPxaqmkbNuws3zh4gWs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -25,8 +34,10 @@ github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
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/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

10
htmgo-site/htmgo.yml Normal file
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

@ -4,8 +4,6 @@ import (
"github.com/maddalax/htmgo/framework/h"
"io/fs"
"os"
"slices"
"strconv"
"strings"
)
@ -36,22 +34,5 @@ func WalkPages(dir string, system fs.FS) []*Page {
return nil
})
var getRouteOrder = func(page *Page) int {
fileName := page.Parts[len(page.Parts)-1]
if len(fileName) > 1 && fileName[1] == '_' {
num, err := strconv.ParseInt(fileName[0:1], 10, 64)
if err != nil {
return 0
}
page.Parts[len(page.Parts)-1] = fileName[2:]
return int(num)
}
return 0
}
slices.SortFunc(pages, func(a *Page, b *Page) int {
return getRouteOrder(a) - getRouteOrder(b)
})
return pages
}

View file

@ -0,0 +1,64 @@
package sitemap
import (
"bytes"
"encoding/xml"
"fmt"
)
type URL struct {
Loc string `xml:"loc"`
ChangeFreq string `xml:"changefreq,omitempty"`
Priority float32 `xml:"priority,omitempty"`
}
type URLSet struct {
XMLName xml.Name `xml:"urlset"`
XmlNS string `xml:"xmlns,attr"`
URLs []URL `xml:"url"`
}
func NewSitemap(urls []URL) *URLSet {
return &URLSet{
XmlNS: "https://www.sitemaps.org/schemas/sitemap/0.9",
URLs: urls,
}
}
func serialize(sitemap *URLSet) ([]byte, error) {
buffer := bytes.Buffer{}
enc := xml.NewEncoder(&buffer)
enc.Indent("", " ")
if err := enc.Encode(sitemap); err != nil {
return make([]byte, 0), fmt.Errorf("could not encode sitemap: %w", err)
}
return buffer.Bytes(), nil
}
func Generate() ([]byte, error) {
urls := []URL{
{
Loc: "/",
Priority: 0.5,
ChangeFreq: "weekly",
},
{
Loc: "/docs",
Priority: 1.0,
ChangeFreq: "daily",
},
{
Loc: "/examples",
Priority: 0.7,
ChangeFreq: "daily",
},
{
Loc: "/html-to-go",
Priority: 0.5,
ChangeFreq: "weekly",
},
}
sitemap := NewSitemap(urls)
return serialize(sitemap)
}

View file

@ -6,6 +6,7 @@ import (
"htmgo-site/__htmgo"
"htmgo-site/internal/cache"
"htmgo-site/internal/markdown"
"htmgo-site/internal/sitemap"
"io/fs"
"net/http"
)
@ -35,6 +36,16 @@ func main() {
http.FileServerFS(sub)
app.Router.Handle("/sitemap.xml", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, err := sitemap.Generate()
if err != nil {
http.Error(w, "failed to generate sitemap", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
w.Write(s)
}))
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
__htmgo.Register(app.Router)

View file

@ -1,4 +1,4 @@
## **Introduction**
## Introduction
htmgo is a lightweight pure go way to build interactive websites / web applications using go & htmx.
We give you the utilities to build html using pure go code in a reusable way (go functions are components) while also providing htmx functions to add interactivity to your app.

View file

@ -1,4 +1,4 @@
## **Getting Started**
## Getting Started
##### **Prerequisites:**

View file

@ -1,4 +1,4 @@
## Pages ##
## Pages
Pages are the entry point of an htmgo application.

View file

@ -1,4 +1,4 @@
## Partials ##
## Partials
Partials are where things get interesting. Partials allow you to start adding interactivity to your website by swapping in content, setting headers, redirecting, etc.

View file

@ -1,4 +1,4 @@
**Components**
## Components
Components are re-usable bits of logic to render HTML. Similar to how in React components are Javascript functions, in htmgo, components are pure go functions.
@ -26,4 +26,4 @@ If you are familiar with React, then you would likely place this fetch logic ins
With **htmgo**, the only way to update content on the page is to use htmx to swap out the content from loading a partial. Therefore you control exactly when this Card component is called, not the framework behind the scenes.
See [#interactivity-swapping](#interactivity-swapping) for more information
See [#interactivity-swapping](#interactivity-swapping) for more information

View file

@ -1,4 +1,4 @@
**HTML Tags**
## HTML Tags
htmgo provides many methods to render html tags:

View file

@ -1,4 +1,4 @@
**Attributes**
## Attributes
Attributes are one of the main ways we can add interactivity to the pages with [htmx](http://htmx.org). If you have not read over the htmx documentation, please do so before continuing.

View file

@ -1,4 +1,4 @@
**Rendering Raw Html**
## Rendering Raw Html
In some cases, you may want to render raw HTML instead of using htmgo's functions.
This can be done by using the following methods:
@ -19,4 +19,4 @@ h.UnsafeRawScript("alert('Hello World')")
Important: Be careful when using these methods, these methods do not escape the HTML content
and should **never** be used with user input unless you have sanitized the input.
Sanitizing input can be done using the `html.EscapeString` function or by using https://github.com/microcosm-cc/bluemonday.
Sanitizing input can be done using the `html.EscapeString` function or by using https://github.com/microcosm-cc/bluemonday.

View file

@ -1,4 +1,4 @@
**If / Else Statements**
## Conditional Statements
If / else statements are useful when you want to conditionally render attributes or elements / components.

View file

@ -1,4 +1,4 @@
**Loops / Dealing With Lists**
## Loops / Dealing With Lists
Very commonly you will need to render a list or slice of items onto the page. Frameworks generally solve this in different ways, such as React uses regular JS .map function to solve it.

View file

@ -1,4 +1,4 @@
### Interactivity
## Interactivity / Swapping
1. Adding interactivity to your website is done through [htmx](http://htmx.org) by utilizing various attributes/headers. This should cover most use cases.
htmgo offers utility methods to make this process a bit easier
@ -82,4 +82,4 @@ When the **CompleteAll** button is clicked, a **POST** will be sent to the **Com
Note: These partial swap methods use https://htmx.org/attributes/hx-swap-oob/ behind the scenes, so it must match
the swap target by id.
**If** you are only wanting to swap the element that made the xhr request for the partial in the first place, just use `h.NewPartial` instead, it will use the default htmx swapping, and not hx-swap-oob.
**If** you are only wanting to swap the element that made the xhr request for the partial in the first place, just use `h.NewPartial` instead, it will use the default htmx swapping, and not hx-swap-oob.

View file

@ -1,4 +1,4 @@
**Events**
## Events Handlers / Commands
Sometimes you need to update elements client side without having to do a network call. For this you generally have to target an element with javascript and set an attribute, change the innerHTML, etc.
@ -42,81 +42,4 @@ OnClick(cmd ...Command) *LifeCycle
HxOnAfterSwap(cmd ...Command) *LifeCycle
HxOnLoad(cmd ...Command) *LifeCycle
```
**Note:** Each command you attach to the event handler will be passed 'self' and 'event' (if applicable) as arguments.
'self' is the current element, and 'event' is the event object.
If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/).
Commands:
```go
js.AddAttribute(string, value)
js.RemoveAttribute(string)
js.AddClass(string, value)
js.SetText(string)
js.Increment(count)
js.SetInnerHtml(Ren)
js.SetOuterHtml(Ren)
js.SetDisabled(bool)
js.RemoveClass(string)
js.Alert(string)
js.EvalJs(string) // eval arbitrary js, use 'self' to get the current element as a reference
js.InjectScript(string)
js.InjectScriptIfNotExist(string)
js.GetPartial(PartialFunc)
js.GetPartialWithQs(PartialFunc, Qs)
js.PostPartial(PartialFunc)
js.PostPartialWithQs(PartialFunc, Qs)
js.GetWithQs(string, Qs)
js.PostWithQs(string, Qs)
js.ToggleClass(string)
js.ToggleClassOnElement(string, string)
// The following methods are used to evaluate JS on nearby elements.
// Use 'element' to get the element as a reference for the EvalJs methods.
js.EvalJsOnParent(string)
js.EvalJsOnSibling(string, string)
js.EvalJsOnChildren(string, string)
js.SetClassOnParent(string)
js.RemoveClassOnParent(string)
js.SetClassOnChildren(string, string)
js.RemoveClassOnChildren(string, string)
js.SetClassOnSibling(string, string)
js.RemoveClassOnSibling(string, string)
```
For more usages: see https://github.com/maddalax/htmgo/blob/master/htmgo-site/pages/form.go
**Example:** Evaluating arbitrary JS
```go
func MyButton() *h.Element {
return h.Button(
h.Text("Submit"),
h.OnClick(
// make sure you use 'self' instead of 'this'
// for referencing the current element
h.EvalJs(`
if(Math.random() > 0.5) {
self.innerHTML = "Success!";
}
`,
),
),
)
}
```
tip: If you are using Jetbrains IDE's, you can write `// language=js` as a comment above the function call (h.EvalJS) and it will automatically give you syntax highlighting on the raw JS.
```go
// language=js
h.EvalJs(`
if(Math.random() > 0.5) {
self.innerHTML = "Success!";
}
`,
),
```

View file

@ -0,0 +1,85 @@
## Evaluating Javascript In Event Handlers
Event handlers are useful by attaching **commands** to elements to execute javascript on the client side.
See [#interactivity-events](#interactivity-events) for more information on event handlers.
<br>
**Note:** Each command you attach to the event handler will be passed 'self' and 'event' (if applicable) as arguments.
'self' is the current element, and 'event' is the event object.
If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/).
Commands:
```go
js.AddAttribute(string, value)
js.RemoveAttribute(string)
js.AddClass(string, value)
js.SetText(string)
js.Increment(count)
js.SetInnerHtml(Ren)
js.SetOuterHtml(Ren)
js.SetDisabled(bool)
js.RemoveClass(string)
js.Alert(string)
js.EvalJs(string) // eval arbitrary js, use 'self' to get the current element as a reference
js.InjectScript(string)
js.InjectScriptIfNotExist(string)
js.GetPartial(PartialFunc)
js.GetPartialWithQs(PartialFunc, Qs)
js.PostPartial(PartialFunc)
js.PostPartialWithQs(PartialFunc, Qs)
js.GetWithQs(string, Qs)
js.PostWithQs(string, Qs)
js.ToggleClass(string)
js.ToggleClassOnElement(string, string)
// The following methods are used to evaluate JS on nearby elements.
// Use 'element' to get the element as a reference for the EvalJs methods.
js.EvalJsOnParent(string)
js.EvalJsOnSibling(string, string)
js.EvalJsOnChildren(string, string)
js.SetClassOnParent(string)
js.RemoveClassOnParent(string)
js.SetClassOnChildren(string, string)
js.RemoveClassOnChildren(string, string)
js.SetClassOnSibling(string, string)
js.RemoveClassOnSibling(string, string)
```
For more usages: see https://github.com/maddalax/htmgo/blob/master/htmgo-site/pages/form.go
**Example:** Evaluating arbitrary JS
```go
func MyButton() *h.Element {
return h.Button(
h.Text("Submit"),
h.OnClick(
// make sure you use 'self' instead of 'this'
// for referencing the current element
h.EvalJs(`
if(Math.random() > 0.5) {
self.innerHTML = "Success!";
}
`,
),
),
)
}
```
tip: If you are using Jetbrains IDE's, you can write `// language=js` as a comment above the function call (h.EvalJS) and it will automatically give you syntax highlighting on the raw JS.
```go
// language=js
h.EvalJs(`
if(Math.random() > 0.5) {
self.innerHTML = "Success!";
}
`,
),
```

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