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
3
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: [maddalax]
|
||||
4
.github/workflows/release-chat-example.yml
vendored
|
|
@ -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
|
|
@ -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
|
||||
7
.github/workflows/release-site.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/release-todo-example.yml
vendored
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
[](https://star-history.com/#maddalax/htmgo&Date)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
31
cli/htmgo/internal/dirutil/glob.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -35,4 +35,4 @@ EXPOSE 3000
|
|||
|
||||
|
||||
# Command to run the binary
|
||||
CMD ["./sse-with-state"]
|
||||
CMD ["./hackernews"]
|
||||
20
examples/hackernews/Taskfile.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
version: '3'
|
||||
|
||||
tasks:
|
||||
run:
|
||||
cmds:
|
||||
- htmgo run
|
||||
silent: true
|
||||
|
||||
build:
|
||||
cmds:
|
||||
- htmgo build
|
||||
|
||||
docker:
|
||||
cmds:
|
||||
- docker build .
|
||||
|
||||
watch:
|
||||
cmds:
|
||||
- htmgo watch
|
||||
silent: true
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"hackernews/internal/embedded"
|
||||
"io/fs"
|
||||
"sse-with-state/internal/embedded"
|
||||
)
|
||||
|
||||
func GetStaticAssets() fs.FS {
|
||||
15
examples/hackernews/assets/css/input.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
BIN
examples/hackernews/assets/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
examples/hackernews/assets/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
examples/hackernews/assets/public/icon-192-maskable.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
examples/hackernews/assets/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
examples/hackernews/assets/public/icon-512-maskable.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
examples/hackernews/assets/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
14
examples/hackernews/components/badge.go
Normal 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...),
|
||||
)
|
||||
}
|
||||
10
examples/hackernews/go.mod
Normal 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
|
||||
)
|
||||
|
|
@ -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=
|
||||
30
examples/hackernews/internal/batch/parallel.go
Normal 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
|
||||
}
|
||||
115
examples/hackernews/internal/httpjson/client.go
Normal 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
|
||||
}
|
||||
146
examples/hackernews/internal/news/news.go
Normal 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
|
||||
}
|
||||
11
examples/hackernews/internal/parse/parse.go
Normal 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)
|
||||
}
|
||||
39
examples/hackernews/internal/timeformat/time.go
Normal 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))))
|
||||
}
|
||||
}
|
||||
36
examples/hackernews/main.go
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
21
examples/hackernews/pages/index.go
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
38
examples/hackernews/pages/root.go
Normal 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...),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
88
examples/hackernews/partials/comments.go
Normal 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("•"),
|
||||
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),
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
151
examples/hackernews/partials/sidebar.go
Normal 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("•"), h.TextF(" %s", timeformat.RelativeTime(item.Time))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
h.UnsafeRaw(fmt.Sprintf("%d upvotes • %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"),
|
||||
)),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
92
examples/hackernews/partials/story.go
Normal 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("•"),
|
||||
h.TextF(" %s ", story.By),
|
||||
h.UnsafeRaw("•"),
|
||||
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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["**/*.go"],
|
||||
plugins: [],
|
||||
plugins: [
|
||||
require('@tailwindcss/typography')
|
||||
],
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
72
framework/assets/js/htmxextensions/sse.ts
Normal 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});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
58
framework/config/project.go
Normal 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
|
||||
}
|
||||
50
framework/config/project_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
htmgo-site/assets/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
htmgo-site/assets/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
htmgo-site/assets/public/hn-example.jpg
Normal file
|
After Width: | Height: | Size: 908 KiB |
BIN
htmgo-site/assets/public/icon-192-maskable.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
htmgo-site/assets/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
htmgo-site/assets/public/icon-512-maskable.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
htmgo-site/assets/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
htmgo-site/assets/public/tailwind-intellisense.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
64
htmgo-site/internal/sitemap/generate.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
## **Getting Started**
|
||||
## Getting Started
|
||||
|
||||
|
||||
##### **Prerequisites:**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
## Pages ##
|
||||
## Pages
|
||||
|
||||
Pages are the entry point of an htmgo application.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
**HTML Tags**
|
||||
## HTML Tags
|
||||
|
||||
htmgo provides many methods to render html tags:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
**If / Else Statements**
|
||||
## Conditional Statements
|
||||
|
||||
If / else statements are useful when you want to conditionally render attributes or elements / components.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!";
|
||||
}
|
||||
`,
|
||||
),
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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!";
|
||||
}
|
||||
`,
|
||||
),
|
||||
```
|
||||