Merge remote-tracking branch 'origin/master'
# Conflicts: # framework/h/attribute.go # framework/h/lifecycle.go # framework/h/render.go
3
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [maddalax]
|
||||||
52
.github/workflows/release-auth-example.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
name: Build and Deploy htmgo auth example
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master # Trigger on pushes to master
|
||||||
|
paths:
|
||||||
|
- 'examples/simple-auth/**' # Trigger only if files in this directory change
|
||||||
|
- "framework-ui/**"
|
||||||
|
- "cli/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get short commit hash
|
||||||
|
id: vars
|
||||||
|
run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)"
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
cd ./examples/simple-auth && docker build -t ghcr.io/${{ github.repository_owner }}/simple-auth:${{ steps.vars.outputs.short_sha }} .
|
||||||
|
|
||||||
|
- name: Tag as latest Docker image
|
||||||
|
run: |
|
||||||
|
docker tag ghcr.io/${{ github.repository_owner }}/simple-auth:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/simple-auth:latest
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push ghcr.io/${{ github.repository_owner }}/simple-auth:latest
|
||||||
7
.github/workflows/release-chat-example.yml
vendored
|
|
@ -1,9 +1,10 @@
|
||||||
name: Build and Deploy htmgo.dev chat example
|
name: Build and Deploy htmgo.dev chat example
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
workflow_run:
|
||||||
branches:
|
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||||
- master
|
types:
|
||||||
|
- completed
|
||||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
|
|
||||||
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
|
||||||
5
.github/workflows/release-site.yml
vendored
|
|
@ -1,13 +1,16 @@
|
||||||
name: Build and Deploy htmgo.dev
|
name: Build and Deploy htmgo.dev
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master # Trigger on pushes to master
|
- master # Trigger on pushes to master
|
||||||
paths:
|
paths:
|
||||||
- 'htmgo-site/**' # Trigger only if files in this directory change
|
- 'htmgo-site/**' # Trigger only if files in this directory change
|
||||||
- "framework/**"
|
|
||||||
- "framework-ui/**"
|
- "framework-ui/**"
|
||||||
- "cli/**"
|
- "cli/**"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
name: Build and Deploy starter template
|
name: Build and Deploy starter template
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
|
|
||||||
4
.github/workflows/release-todo-example.yml
vendored
|
|
@ -1,6 +1,10 @@
|
||||||
name: Build and Deploy htmgo.dev todo example
|
name: Build and Deploy htmgo.dev todo example
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
|
|
||||||
3
.github/workflows/update-framework-dep.yml
vendored
|
|
@ -6,7 +6,8 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- master # Trigger on pushes to master
|
- master # Trigger on pushes to master
|
||||||
paths:
|
paths:
|
||||||
- 'framework/**' # Trigger only if files in this directory change
|
- 'framework/**'
|
||||||
|
- 'tools/html-to-htmgo/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-htmgo-dep:
|
update-htmgo-dep:
|
||||||
|
|
|
||||||
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.
|
||||||
10
README.md
|
|
@ -1,6 +1,3 @@
|
||||||
> [!WARNING]
|
|
||||||
> htmgo is in alpha release. Please report any issues on GitHub.
|
|
||||||
|
|
||||||
## **htmgo**
|
## **htmgo**
|
||||||
|
|
||||||
### build simple and scalable systems with go + htmx
|
### build simple and scalable systems with go + htmx
|
||||||
|
|
@ -34,9 +31,12 @@ func IndexPage(ctx *h.RequestContext) *h.Page {
|
||||||
2. live reload (rebuilds css, go, ent schema, and routes upon change)
|
2. live reload (rebuilds css, go, ent schema, and routes upon change)
|
||||||
3. automatic page and partial registration based on file path
|
3. automatic page and partial registration based on file path
|
||||||
4. built in tailwindcss support, no need to configure anything by default
|
4. built in tailwindcss support, no need to configure anything by default
|
||||||
5. plugin architecture to include optional plugins to streamline development, such as http://entgo.io
|
5. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
|
||||||
6. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
|
|
||||||
|
|
||||||
**get started:**
|
**get started:**
|
||||||
|
|
||||||
View documentation on [htmgo.dev](https://htmgo.dev/docs).
|
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/sys v0.25.0
|
||||||
golang.org/x/tools 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 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
|
||||||
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
|
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
|
|
||||||
|
|
@ -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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||||
|
"github.com/maddalax/htmgo/framework/config"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -17,6 +18,10 @@ func HasFileFromRoot(file string) bool {
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetConfig() *config.ProjectConfig {
|
||||||
|
return config.FromConfigFile(process.GetWorkingDir())
|
||||||
|
}
|
||||||
|
|
||||||
func CreateHtmgoDir() {
|
func CreateHtmgoDir() {
|
||||||
if !HasFileFromRoot("__htmgo") {
|
if !HasFileFromRoot("__htmgo") {
|
||||||
CreateDirFromRoot("__htmgo")
|
CreateDirFromRoot("__htmgo")
|
||||||
|
|
@ -71,7 +76,7 @@ func MoveFile(src, dst string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to copy file: %v", err)
|
return fmt.Errorf("failed to copy file: %v", err)
|
||||||
}
|
}
|
||||||
// Disconnect the source file.
|
// Remove the source file.
|
||||||
err = os.Remove(src)
|
err = os.Remove(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove source file: %v", err)
|
return fmt.Errorf("failed to remove source file: %v", err)
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/downloadtemplate"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/downloadtemplate"
|
||||||
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/formatter"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/reloader"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/reloader"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
|
||||||
|
|
@ -19,10 +20,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
done := RegisterSignals()
|
needsSignals := true
|
||||||
|
|
||||||
commandMap := make(map[string]*flag.FlagSet)
|
commandMap := make(map[string]*flag.FlagSet)
|
||||||
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate"}
|
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate", "format"}
|
||||||
|
|
||||||
for _, command := range commands {
|
for _, command := range commands {
|
||||||
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
|
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
|
||||||
|
|
@ -56,6 +57,15 @@ func main() {
|
||||||
slog.Debug("Running task:", slog.String("task", taskName))
|
slog.Debug("Running task:", slog.String("task", taskName))
|
||||||
slog.Debug("working dir:", slog.String("dir", process.GetWorkingDir()))
|
slog.Debug("working dir:", slog.String("dir", process.GetWorkingDir()))
|
||||||
|
|
||||||
|
if taskName == "format" {
|
||||||
|
needsSignals = false
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
if needsSignals {
|
||||||
|
done = RegisterSignals()
|
||||||
|
}
|
||||||
|
|
||||||
if taskName == "watch" {
|
if taskName == "watch" {
|
||||||
fmt.Printf("Running in watch mode\n")
|
fmt.Printf("Running in watch mode\n")
|
||||||
os.Setenv("ENV", "development")
|
os.Setenv("ENV", "development")
|
||||||
|
|
@ -90,7 +100,18 @@ func main() {
|
||||||
}()
|
}()
|
||||||
startWatcher(reloader.OnFileChange)
|
startWatcher(reloader.OnFileChange)
|
||||||
} else {
|
} else {
|
||||||
if taskName == "schema" {
|
if taskName == "format" {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Println(fmt.Sprintf("Usage: htmgo format <file>"))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
file := os.Args[2]
|
||||||
|
if file == "." {
|
||||||
|
formatter.FormatDir(process.GetWorkingDir())
|
||||||
|
} else {
|
||||||
|
formatter.FormatFile(os.Args[2])
|
||||||
|
}
|
||||||
|
} else if taskName == "schema" {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
fmt.Print("Enter entity name:")
|
fmt.Print("Enter entity name:")
|
||||||
text, _ := reader.ReadString('\n')
|
text, _ := reader.ReadString('\n')
|
||||||
|
|
@ -106,8 +127,7 @@ func main() {
|
||||||
} else if taskName == "ast" {
|
} else if taskName == "ast" {
|
||||||
_ = astgen.GenAst(process.ExitOnError)
|
_ = astgen.GenAst(process.ExitOnError)
|
||||||
} else if taskName == "run" {
|
} else if taskName == "run" {
|
||||||
_ = astgen.GenAst(process.ExitOnError)
|
run.MakeBuildable()
|
||||||
_ = css.GenerateCss(process.ExitOnError)
|
|
||||||
_ = run.Server(process.ExitOnError)
|
_ = run.Server(process.ExitOnError)
|
||||||
} else if taskName == "template" {
|
} else if taskName == "template" {
|
||||||
name := ""
|
name := ""
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ package astgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
"go/ast"
|
"go/ast"
|
||||||
"go/parser"
|
"go/parser"
|
||||||
"go/token"
|
"go/token"
|
||||||
|
|
@ -24,6 +26,7 @@ type Partial struct {
|
||||||
FuncName string
|
FuncName string
|
||||||
Package string
|
Package string
|
||||||
Import string
|
Import string
|
||||||
|
Path string
|
||||||
}
|
}
|
||||||
|
|
||||||
const GeneratedDirName = "__htmgo"
|
const GeneratedDirName = "__htmgo"
|
||||||
|
|
@ -53,7 +56,7 @@ func sliceCommonPrefix(dir1, dir2 string) string {
|
||||||
slicedDir1 := strings.TrimPrefix(dir1, commonPrefix)
|
slicedDir1 := strings.TrimPrefix(dir1, commonPrefix)
|
||||||
slicedDir2 := strings.TrimPrefix(dir2, commonPrefix)
|
slicedDir2 := strings.TrimPrefix(dir2, commonPrefix)
|
||||||
|
|
||||||
// Disconnect leading slashes
|
// Remove leading slashes
|
||||||
slicedDir1 = strings.TrimPrefix(slicedDir1, string(filepath.Separator))
|
slicedDir1 = strings.TrimPrefix(slicedDir1, string(filepath.Separator))
|
||||||
slicedDir2 = strings.TrimPrefix(slicedDir2, string(filepath.Separator))
|
slicedDir2 = strings.TrimPrefix(slicedDir2, string(filepath.Separator))
|
||||||
|
|
||||||
|
|
@ -103,6 +106,7 @@ func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial
|
||||||
if selectorExpr.Sel.Name == "Partial" {
|
if selectorExpr.Sel.Name == "Partial" {
|
||||||
p := Partial{
|
p := Partial{
|
||||||
Package: node.Name.Name,
|
Package: node.Name.Name,
|
||||||
|
Path: sliceCommonPrefix(cwd, path),
|
||||||
Import: sliceCommonPrefix(cwd, strings.ReplaceAll(filepath.Dir(path), `\`, `/`)),
|
Import: sliceCommonPrefix(cwd, strings.ReplaceAll(filepath.Dir(path), `\`, `/`)),
|
||||||
FuncName: funcDecl.Name.Name,
|
FuncName: funcDecl.Name.Name,
|
||||||
}
|
}
|
||||||
|
|
@ -254,12 +258,18 @@ func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func writePartialsFile() {
|
func writePartialsFile() {
|
||||||
|
config := dirutil.GetConfig()
|
||||||
|
|
||||||
cwd := process.GetWorkingDir()
|
cwd := process.GetWorkingDir()
|
||||||
partialPath := filepath.Join(cwd, "partials")
|
partialPath := filepath.Join(cwd, "partials")
|
||||||
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
|
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
|
||||||
return partial.FuncName != "GetPartialFromContext"
|
return partial.FuncName != "GetPartialFromContext"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
partials = h.Filter(partials, func(partial Partial) bool {
|
||||||
|
return !dirutil.IsGlobExclude(partial.Path, config.AutomaticPartialRoutingIgnore)
|
||||||
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return
|
return
|
||||||
|
|
@ -317,6 +327,7 @@ func formatRoute(path string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func writePagesFile() {
|
func writePagesFile() {
|
||||||
|
config := dirutil.GetConfig()
|
||||||
|
|
||||||
builder := NewCodeBuilder(nil)
|
builder := NewCodeBuilder(nil)
|
||||||
builder.AppendLine(GeneratedFileLine)
|
builder.AppendLine(GeneratedFileLine)
|
||||||
|
|
@ -326,6 +337,10 @@ func writePagesFile() {
|
||||||
|
|
||||||
pages, _ := findPublicFuncsReturningHPage("pages")
|
pages, _ := findPublicFuncsReturningHPage("pages")
|
||||||
|
|
||||||
|
pages = h.Filter(pages, func(page Page) bool {
|
||||||
|
return !dirutil.IsGlobExclude(page.Path, config.AutomaticPageRoutingIgnore)
|
||||||
|
})
|
||||||
|
|
||||||
if len(pages) > 0 {
|
if len(pages) > 0 {
|
||||||
builder.AddImport(ModuleName)
|
builder.AddImport(ModuleName)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,10 @@ func (om *OrderedMap[K, V]) Values() []V {
|
||||||
// Delete removes a key-value pair from the OrderedMap.
|
// Delete removes a key-value pair from the OrderedMap.
|
||||||
func (om *OrderedMap[K, V]) Delete(key K) {
|
func (om *OrderedMap[K, V]) Delete(key K) {
|
||||||
if _, exists := om.values[key]; exists {
|
if _, exists := om.values[key]; exists {
|
||||||
// Disconnect the key from the map
|
// Remove the key from the map
|
||||||
delete(om.values, key)
|
delete(om.values, key)
|
||||||
|
|
||||||
// Disconnect the key from the keys slice
|
// Remove the key from the keys slice
|
||||||
for i, k := range om.keys {
|
for i, k := range om.keys {
|
||||||
if k == key {
|
if k == key {
|
||||||
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ func CopyAssets() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dirutil.HasFileFromRoot("tailwind.config.js") {
|
if dirutil.GetConfig().Tailwind && !dirutil.HasFileFromRoot("tailwind.config.js") {
|
||||||
err = dirutil.CopyFile(
|
err = dirutil.CopyFile(
|
||||||
filepath.Join(assetCssDir, "tailwind.config.js"),
|
filepath.Join(assetCssDir, "tailwind.config.js"),
|
||||||
filepath.Join(process.GetWorkingDir(), "tailwind.config.js"),
|
filepath.Join(process.GetWorkingDir(), "tailwind.config.js"),
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func IsTailwindEnabled() bool {
|
func IsTailwindEnabled() bool {
|
||||||
return dirutil.HasFileFromRoot("tailwind.config.js")
|
return dirutil.GetConfig().Tailwind && dirutil.HasFileFromRoot("tailwind.config.js")
|
||||||
}
|
}
|
||||||
|
|
||||||
func Setup() bool {
|
func Setup() bool {
|
||||||
|
|
|
||||||
50
cli/htmgo/tasks/formatter/formatter.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package formatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/tools/html-to-htmgo/htmltogo"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FormatDir(dir string) {
|
||||||
|
files, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error reading dir: %s\n", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() {
|
||||||
|
FormatDir(filepath.Join(dir, file.Name()))
|
||||||
|
} else {
|
||||||
|
FormatFile(filepath.Join(dir, file.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatFile(file string) {
|
||||||
|
if !strings.HasSuffix(file, ".go") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("formatting file: %s\n", file)
|
||||||
|
|
||||||
|
source, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error reading file: %s\n", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
str := string(source)
|
||||||
|
|
||||||
|
if !strings.Contains(str, "github.com/maddalax/htmgo/framework/h") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := htmltogo.Indent(str)
|
||||||
|
|
||||||
|
os.WriteFile(file, []byte(parsed), 0644)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
@ -115,7 +115,7 @@ func OnShutdown() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// give it a second
|
// give it a second
|
||||||
time.Sleep(time.Second * 2)
|
time.Sleep(time.Second * 1)
|
||||||
// force kill
|
// force kill
|
||||||
KillAll()
|
KillAll()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,11 @@ func OnFileChange(version string, events []*fsnotify.Event) {
|
||||||
//tasks.Run = true
|
//tasks.Run = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// something in public folder changed
|
||||||
|
if c.HasAnyPrefix("assets/public/") {
|
||||||
|
copyassets.CopyAssets()
|
||||||
|
}
|
||||||
|
|
||||||
if hasTask {
|
if hasTask {
|
||||||
slog.Info("file changed", slog.String("version", version), slog.String("file", c.Name()))
|
slog.Info("file changed", slog.String("version", version), slog.String("file", c.Name()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,14 @@ import (
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Build() {
|
func MakeBuildable() {
|
||||||
copyassets.CopyAssets()
|
copyassets.CopyAssets()
|
||||||
astgen.GenAst(process.ExitOnError)
|
astgen.GenAst(process.ExitOnError)
|
||||||
css.GenerateCss(process.ExitOnError)
|
css.GenerateCss(process.ExitOnError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Build() {
|
||||||
|
MakeBuildable()
|
||||||
|
|
||||||
process.RunOrExit(process.NewRawCommand("", "mkdir -p ./dist"))
|
process.RunOrExit(process.NewRawCommand("", "mkdir -p ./dist"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
package run
|
package run
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/astgen"
|
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
|
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
|
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Setup() {
|
func Setup() {
|
||||||
process.RunOrExit(process.NewRawCommand("", "go mod download"))
|
process.RunOrExit(process.NewRawCommand("", "go mod download"))
|
||||||
process.RunOrExit(process.NewRawCommand("", "go mod tidy"))
|
process.RunOrExit(process.NewRawCommand("", "go mod tidy"))
|
||||||
|
MakeBuildable()
|
||||||
copyassets.CopyAssets()
|
|
||||||
astgen.GenAst(process.ExitOnError)
|
|
||||||
css.GenerateCss(process.ExitOnError)
|
|
||||||
|
|
||||||
EntGenerate()
|
EntGenerate()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ func ReplaceTextInFile(file string, text string, replacement string) error {
|
||||||
|
|
||||||
func ReplaceTextInDirRecursive(dir string, text string, replacement string, filter func(file string) bool) error {
|
func ReplaceTextInDirRecursive(dir string, text string, replacement string, filter func(file string) bool) error {
|
||||||
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
if filter(path) {
|
if filter(filepath.Base(path)) {
|
||||||
_ = ReplaceTextInFile(path, text, replacement)
|
_ = ReplaceTextInFile(path, text, replacement)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/internal"
|
"github.com/maddalax/htmgo/cli/htmgo/internal"
|
||||||
|
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/module"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/module"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
@ -13,11 +14,10 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ignoredDirs = []string{".git", ".idea", "node_modules", "vendor"}
|
|
||||||
|
|
||||||
func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||||
events := make([]*fsnotify.Event, 0)
|
events := make([]*fsnotify.Event, 0)
|
||||||
debouncer := internal.NewDebouncer(500 * time.Millisecond)
|
debouncer := internal.NewDebouncer(500 * time.Millisecond)
|
||||||
|
config := dirutil.GetConfig()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
|
@ -38,8 +38,38 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 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)
|
events = append(events, &event)
|
||||||
debouncer.Do(func() {
|
debouncer.Do(func() {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
@ -54,6 +84,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||||
events = make([]*fsnotify.Event, 0)
|
events = make([]*fsnotify.Event, 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
case err, ok := <-watcher.Errors:
|
case err, ok := <-watcher.Errors:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
|
|
@ -79,11 +110,10 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Ignore directories in the ignoredDirs list
|
// Ignore directories in the ignoredDirs list
|
||||||
for _, ignoredDir := range ignoredDirs {
|
if dirutil.IsGlobExclude(path, config.WatchIgnore) {
|
||||||
if ignoredDir == info.Name() {
|
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Only watch directories
|
// Only watch directories
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
err = watcher.Add(path)
|
err = watcher.Add(path)
|
||||||
|
|
@ -95,6 +125,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the Go binary for Linux
|
# Build the Go binary for Linux
|
||||||
RUN CGO_ENABLED=0 GOPRIVATE=github.com/maddalax LOG_LEVEL=debug go run github.com/maddalax/htmgo/cli/htmgo@8b816e956692683337d9fff6416ccc31f5047b59 build
|
RUN CGO_ENABLED=0 GOPRIVATE=github.com/maddalax LOG_LEVEL=debug go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||||
|
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -tags prod -o ./dist -a -ldflags '-linkmode external -extldflags "-static"' .
|
RUN CGO_ENABLED=1 GOOS=linux go build -tags prod -o ./dist -a -ldflags '-linkmode external -extldflags "-static"' .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"chat/internal/db"
|
"chat/internal/db"
|
||||||
"chat/ws"
|
"chat/sse"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/maddalax/htmgo/framework/h"
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
|
@ -11,79 +11,86 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
socketManager *ws.SocketManager
|
socketManager *sse.SocketManager
|
||||||
queries *db.Queries
|
queries *db.Queries
|
||||||
service *Service
|
service *Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(locator *service.Locator) *Manager {
|
func NewManager(locator *service.Locator) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
socketManager: service.Get[ws.SocketManager](locator),
|
socketManager: service.Get[sse.SocketManager](locator),
|
||||||
queries: service.Get[db.Queries](locator),
|
queries: service.Get[db.Queries](locator),
|
||||||
service: NewService(locator),
|
service: NewService(locator),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) StartListener() {
|
func (m *Manager) StartListener() {
|
||||||
c := make(chan ws.SocketEvent)
|
c := make(chan sse.SocketEvent, 1)
|
||||||
m.socketManager.Listen(c)
|
m.socketManager.Listen(c)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case event := <-c:
|
case event := <-c:
|
||||||
switch event.Type {
|
switch event.Type {
|
||||||
case ws.ConnectedEvent:
|
case sse.ConnectedEvent:
|
||||||
m.OnConnected(event)
|
m.OnConnected(event)
|
||||||
case ws.DisconnectedEvent:
|
case sse.DisconnectedEvent:
|
||||||
m.OnDisconnected(event)
|
m.OnDisconnected(event)
|
||||||
case ws.MessageEvent:
|
case sse.MessageEvent:
|
||||||
m.onMessage(event)
|
m.onMessage(event)
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown event type: %s\n", event.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) OnConnected(e ws.SocketEvent) {
|
func (m *Manager) dispatchConnectedUsers(roomId string, predicate func(conn sse.SocketConnection) bool) {
|
||||||
|
|
||||||
|
connectedUsers := make([]db.User, 0)
|
||||||
|
|
||||||
|
// backfill all existing clients to the connected client
|
||||||
|
m.socketManager.ForEachSocket(roomId, func(conn sse.SocketConnection) {
|
||||||
|
if !predicate(conn) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := m.queries.GetUserBySessionId(context.Background(), conn.Id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectedUsers = append(connectedUsers, user)
|
||||||
|
})
|
||||||
|
|
||||||
|
m.socketManager.ForEachSocket(roomId, func(conn sse.SocketConnection) {
|
||||||
|
m.socketManager.SendText(conn.Id, h.Render(ConnectedUsers(connectedUsers, conn.Id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) OnConnected(e sse.SocketEvent) {
|
||||||
room, _ := m.service.GetRoom(e.RoomId)
|
room, _ := m.service.GetRoom(e.RoomId)
|
||||||
|
|
||||||
if room == nil {
|
if room == nil {
|
||||||
m.socketManager.CloseWithError(e.Id, 1008, "invalid room")
|
m.socketManager.CloseWithMessage(e.Id, "invalid room")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.socketManager.CloseWithError(e.Id, 1008, "invalid user")
|
m.socketManager.CloseWithMessage(e.Id, "invalid user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("User %s connected to %s\n", user.Name, e.RoomId)
|
fmt.Printf("User %s connected to %s\n", user.Name, e.RoomId)
|
||||||
|
|
||||||
// backfill all existing clients to the connected client
|
m.dispatchConnectedUsers(e.RoomId, func(conn sse.SocketConnection) bool {
|
||||||
m.socketManager.ForEachSocket(e.RoomId, func(conn ws.SocketConnection) {
|
return true
|
||||||
user, err := m.queries.GetUserBySessionId(context.Background(), conn.Id)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isMe := conn.Id == e.Id
|
|
||||||
fmt.Printf("Sending connected user %s to %s\n", user.Name, e.Id)
|
|
||||||
m.socketManager.SendText(e.Id, h.Render(ConnectedUsers(user.Name, isMe)))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// send the connected user to all existing clients
|
m.backFill(e.Id, e.RoomId)
|
||||||
m.socketManager.BroadcastText(
|
|
||||||
e.RoomId,
|
|
||||||
h.Render(ConnectedUsers(user.Name, false)),
|
|
||||||
func(conn ws.SocketConnection) bool {
|
|
||||||
return conn.Id != e.Id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
go m.backFill(e.Id, e.RoomId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) OnDisconnected(e ws.SocketEvent) {
|
func (m *Manager) OnDisconnected(e sse.SocketEvent) {
|
||||||
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|
@ -93,7 +100,7 @@ func (m *Manager) OnDisconnected(e ws.SocketEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("User %s disconnected from %s\n", user.Name, room.ID)
|
fmt.Printf("User %s disconnected from %s\n", user.Name, room.ID)
|
||||||
m.socketManager.BroadcastText(room.ID, h.Render(ConnectedUser(user.Name, true, false)), func(conn ws.SocketConnection) bool {
|
m.dispatchConnectedUsers(e.RoomId, func(conn sse.SocketConnection) bool {
|
||||||
return conn.Id != e.Id
|
return conn.Id != e.Id
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +123,7 @@ func (m *Manager) backFill(socketId string, roomId string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) onMessage(e ws.SocketEvent) {
|
func (m *Manager) onMessage(e sse.SocketEvent) {
|
||||||
message := e.Payload["message"].(string)
|
message := e.Payload["message"].(string)
|
||||||
|
|
||||||
if message == "" {
|
if message == "" {
|
||||||
|
|
@ -140,7 +147,7 @@ func (m *Manager) onMessage(e ws.SocketEvent) {
|
||||||
m.socketManager.BroadcastText(
|
m.socketManager.BroadcastText(
|
||||||
e.RoomId,
|
e.RoomId,
|
||||||
h.Render(MessageRow(saved)),
|
h.Render(MessageRow(saved)),
|
||||||
func(conn ws.SocketConnection) bool {
|
func(conn sse.SocketConnection) bool {
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"chat/internal/db"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/maddalax/htmgo/framework/h"
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -10,39 +11,43 @@ import (
|
||||||
func MessageRow(message *Message) *h.Element {
|
func MessageRow(message *Message) *h.Element {
|
||||||
return h.Div(
|
return h.Div(
|
||||||
h.Attribute("hx-swap-oob", "beforeend"),
|
h.Attribute("hx-swap-oob", "beforeend"),
|
||||||
h.Class("flex flex-col gap-4 w-full break-words whitespace-normal"), // Ensure container breaks long words
|
h.Class("flex flex-col gap-4 w-full break-words whitespace-normal"),
|
||||||
|
// Ensure container breaks long words
|
||||||
h.Id("messages"),
|
h.Id("messages"),
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("flex flex-col gap-1"),
|
h.Class("flex flex-col gap-1"),
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("flex gap-2 items-center"),
|
h.Class("flex gap-2 items-center"),
|
||||||
h.Pf(message.UserName, h.Class("font-bold")),
|
h.Pf(
|
||||||
|
message.UserName,
|
||||||
|
h.Class("font-bold"),
|
||||||
|
),
|
||||||
h.Pf(message.CreatedAt.In(time.Local).Format("01/02 03:04 PM")),
|
h.Pf(message.CreatedAt.In(time.Local).Format("01/02 03:04 PM")),
|
||||||
),
|
),
|
||||||
h.Article(
|
h.Article(
|
||||||
h.Class("break-words whitespace-normal"), // Ensure message text wraps correctly
|
h.Class("break-words whitespace-normal"),
|
||||||
h.P(h.Text(message.Message)),
|
// Ensure message text wraps correctly
|
||||||
|
h.P(
|
||||||
|
h.Text(message.Message),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConnectedUsers(username string, isMe bool) *h.Element {
|
func ConnectedUsers(users []db.User, myId string) *h.Element {
|
||||||
return h.Ul(
|
return h.Ul(
|
||||||
h.Attribute("hx-swap", "none"),
|
h.Attribute("hx-swap-oob", "outerHTML"),
|
||||||
h.Attribute("hx-swap-oob", "beforeend"),
|
|
||||||
h.Id("connected-users"),
|
h.Id("connected-users"),
|
||||||
h.Class("flex flex-col"),
|
h.Class("flex flex-col"),
|
||||||
// This would be populated dynamically with connected users
|
h.List(users, func(user db.User, index int) *h.Element {
|
||||||
ConnectedUser(username, false, isMe),
|
return connectedUser(user.Name, user.SessionID == myId)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConnectedUser(username string, remove bool, isMe bool) *h.Element {
|
func connectedUser(username string, isMe bool) *h.Element {
|
||||||
id := fmt.Sprintf("connected-user-%s", strings.ReplaceAll(username, "#", "-"))
|
id := fmt.Sprintf("connected-user-%s", strings.ReplaceAll(username, "#", "-"))
|
||||||
if remove {
|
|
||||||
return h.Div(h.Id(id), h.Attribute("hx-swap-oob", "delete"))
|
|
||||||
}
|
|
||||||
return h.Li(
|
return h.Li(
|
||||||
h.Id(id),
|
h.Id(id),
|
||||||
h.ClassX("truncate text-slate-700", h.ClassMap{
|
h.ClassX("truncate text-slate-700", h.ClassMap{
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,28 @@ func Button(props ButtonProps) h.Ren {
|
||||||
text := h.Text(props.Text)
|
text := h.Text(props.Text)
|
||||||
|
|
||||||
button := h.Button(
|
button := h.Button(
|
||||||
h.If(props.Id != "", h.Id(props.Id)),
|
h.If(
|
||||||
h.If(props.Children != nil, h.Children(props.Children...)),
|
props.Id != "",
|
||||||
|
h.Id(props.Id),
|
||||||
|
),
|
||||||
|
h.If(
|
||||||
|
props.Children != nil,
|
||||||
|
h.Children(props.Children...),
|
||||||
|
),
|
||||||
h.Class("flex gap-1 items-center justify-center border p-4 rounded cursor-hover", props.Class),
|
h.Class("flex gap-1 items-center justify-center border p-4 rounded cursor-hover", props.Class),
|
||||||
h.If(props.Get != "", h.Get(props.Get)),
|
h.If(
|
||||||
h.If(props.Target != "", h.HxTarget(props.Target)),
|
props.Get != "",
|
||||||
h.IfElse(props.Type != "", h.Type(props.Type), h.Type("button")),
|
h.Get(props.Get),
|
||||||
|
),
|
||||||
|
h.If(
|
||||||
|
props.Target != "",
|
||||||
|
h.HxTarget(props.Target),
|
||||||
|
),
|
||||||
|
h.IfElse(
|
||||||
|
props.Type != "",
|
||||||
|
h.Type(props.Type),
|
||||||
|
h.Type("button"),
|
||||||
|
),
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ func FormError(error string) *h.Element {
|
||||||
return h.Div(
|
return h.Div(
|
||||||
h.Id("form-error"),
|
h.Id("form-error"),
|
||||||
h.Text(error),
|
h.Text(error),
|
||||||
h.If(error != "", h.Class("p-4 bg-rose-400 text-white rounded")),
|
h.If(
|
||||||
|
error != "",
|
||||||
|
h.Class("p-4 bg-rose-400 text-white rounded"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,14 @@ type InputProps struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Input(props InputProps) *h.Element {
|
func Input(props InputProps) *h.Element {
|
||||||
validation := h.If(props.ValidationPath != "", h.Children(
|
validation := h.If(
|
||||||
|
props.ValidationPath != "",
|
||||||
|
h.Children(
|
||||||
h.Post(props.ValidationPath, hx.BlurEvent),
|
h.Post(props.ValidationPath, hx.BlurEvent),
|
||||||
h.Attribute("hx-swap", "innerHTML transition:true"),
|
h.Attribute("hx-swap", "innerHTML transition:true"),
|
||||||
h.Attribute("hx-target", "next div"),
|
h.Attribute("hx-target", "next div"),
|
||||||
))
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if props.Type == "" {
|
if props.Type == "" {
|
||||||
props.Type = "text"
|
props.Type = "text"
|
||||||
|
|
@ -32,18 +35,41 @@ func Input(props InputProps) *h.Element {
|
||||||
input := h.Input(
|
input := h.Input(
|
||||||
props.Type,
|
props.Type,
|
||||||
h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"),
|
h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"),
|
||||||
h.If(props.Name != "", h.Name(props.Name)),
|
h.If(
|
||||||
h.If(props.Children != nil, h.Children(props.Children...)),
|
props.Name != "",
|
||||||
h.If(props.Required, h.Required()),
|
h.Name(props.Name),
|
||||||
h.If(props.Placeholder != "", h.Placeholder(props.Placeholder)),
|
),
|
||||||
h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)),
|
h.If(
|
||||||
|
props.Children != nil,
|
||||||
|
h.Children(props.Children...),
|
||||||
|
),
|
||||||
|
h.If(
|
||||||
|
props.Required,
|
||||||
|
h.Required(),
|
||||||
|
),
|
||||||
|
h.If(
|
||||||
|
props.Placeholder != "",
|
||||||
|
h.Placeholder(props.Placeholder),
|
||||||
|
),
|
||||||
|
h.If(
|
||||||
|
props.DefaultValue != "",
|
||||||
|
h.Attribute("value", props.DefaultValue),
|
||||||
|
),
|
||||||
validation,
|
validation,
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapped := h.Div(
|
wrapped := h.Div(
|
||||||
h.If(props.Id != "", h.Id(props.Id)),
|
h.If(
|
||||||
|
props.Id != "",
|
||||||
|
h.Id(props.Id),
|
||||||
|
),
|
||||||
h.Class("flex flex-col gap-1"),
|
h.Class("flex flex-col gap-1"),
|
||||||
h.If(props.Label != "", h.Label(h.Text(props.Label))),
|
h.If(
|
||||||
|
props.Label != "",
|
||||||
|
h.Label(
|
||||||
|
h.Text(props.Label),
|
||||||
|
),
|
||||||
|
),
|
||||||
input,
|
input,
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Id(props.Id+"-error"),
|
h.Id(props.Id+"-error"),
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@ module chat
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coder/websocket v1.8.12
|
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692
|
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0
|
||||||
github.com/mattn/go-sqlite3 v1.14.23
|
github.com/mattn/go-sqlite3 v1.14.23
|
||||||
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
|
||||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/maddalax/htmgo/framework v0.0.0-20241001184532-9a5b92987701 h1:0Zk282axc1kPiuspLNzK5BJV7cQ5h2kPZHe54dznhYY=
|
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0 h1:K9Q5b7BmbpCPJFjrAHS8+wPdKDcZN9NMC3Fg51n5IaQ=
|
||||||
github.com/maddalax/htmgo/framework v0.0.0-20241001184532-9a5b92987701/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
|
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||||
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692 h1:NtLJ7GcD9hWvPYmombxC1SzVNgvnhLXWhZEQJZOstik=
|
|
||||||
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|
|
||||||
25
examples/chat/internal/routine/goroutine.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package routine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DebugLongRunning(name string, f func()) {
|
||||||
|
now := time.Now()
|
||||||
|
done := make(chan struct{}, 1)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Second * 5)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
elapsed := time.Since(now).Milliseconds()
|
||||||
|
fmt.Printf("function %s has not finished after %dms\n", name, elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
f()
|
||||||
|
done <- struct{}{}
|
||||||
|
}
|
||||||
|
|
@ -4,24 +4,35 @@ import (
|
||||||
"chat/__htmgo"
|
"chat/__htmgo"
|
||||||
"chat/chat"
|
"chat/chat"
|
||||||
"chat/internal/db"
|
"chat/internal/db"
|
||||||
"chat/ws"
|
"chat/sse"
|
||||||
|
"fmt"
|
||||||
"github.com/maddalax/htmgo/framework/h"
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
"github.com/maddalax/htmgo/framework/service"
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
locator := service.NewLocator()
|
locator := service.NewLocator()
|
||||||
|
|
||||||
service.Set[db.Queries](locator, service.Singleton, db.Provide)
|
service.Set[db.Queries](locator, service.Singleton, db.Provide)
|
||||||
service.Set[ws.SocketManager](locator, service.Singleton, func() *ws.SocketManager {
|
service.Set[sse.SocketManager](locator, service.Singleton, func() *sse.SocketManager {
|
||||||
return ws.NewSocketManager()
|
return sse.NewSocketManager()
|
||||||
})
|
})
|
||||||
|
|
||||||
chatManager := chat.NewManager(locator)
|
chatManager := chat.NewManager(locator)
|
||||||
go chatManager.StartListener()
|
go chatManager.StartListener()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
count := runtime.NumGoroutine()
|
||||||
|
fmt.Printf("goroutines: %d\n", count)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
h.Start(h.AppOpts{
|
h.Start(h.AppOpts{
|
||||||
ServiceLocator: locator,
|
ServiceLocator: locator,
|
||||||
LiveReload: true,
|
LiveReload: true,
|
||||||
|
|
@ -35,7 +46,7 @@ func main() {
|
||||||
http.FileServerFS(sub)
|
http.FileServerFS(sub)
|
||||||
|
|
||||||
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
|
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
|
||||||
app.Router.Handle("/ws/chat/{id}", ws.Handle())
|
app.Router.Handle("/sse/chat/{id}", sse.Handle())
|
||||||
|
|
||||||
__htmgo.Register(app.Router)
|
__htmgo.Register(app.Router)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"chat/chat"
|
"chat/chat"
|
||||||
|
"chat/internal/db"
|
||||||
"chat/partials"
|
"chat/partials"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
@ -15,20 +16,14 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
|
||||||
return h.NewPage(
|
return h.NewPage(
|
||||||
RootPage(
|
RootPage(
|
||||||
h.Div(
|
h.Div(
|
||||||
h.JoinExtensions(
|
|
||||||
h.TriggerChildren(),
|
h.TriggerChildren(),
|
||||||
h.HxExtension("ws"),
|
h.Attribute("sse-connect", fmt.Sprintf("/sse/chat/%s", roomId)),
|
||||||
),
|
|
||||||
|
|
||||||
h.Attribute("sse-connect", fmt.Sprintf("/ws/chat/%s", roomId)),
|
|
||||||
|
|
||||||
h.HxOnSseOpen(
|
h.HxOnSseOpen(
|
||||||
js.ConsoleLog("Connected to chat room"),
|
js.ConsoleLog("Connected to chat room"),
|
||||||
),
|
),
|
||||||
|
h.HxOnSseError(
|
||||||
h.HxOnSseClose(
|
|
||||||
js.EvalJs(fmt.Sprintf(`
|
js.EvalJs(fmt.Sprintf(`
|
||||||
const reason = e.detail.event.reason
|
const reason = e.detail.event.data
|
||||||
if(['invalid room', 'no session', 'invalid user'].includes(reason)) {
|
if(['invalid room', 'no session', 'invalid user'].includes(reason)) {
|
||||||
window.location.href = '/?roomId=%s';
|
window.location.href = '/?roomId=%s';
|
||||||
} else if(e.detail.event.code === 1011) {
|
} else if(e.detail.event.code === 1011) {
|
||||||
|
|
@ -40,40 +35,32 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
|
||||||
}
|
}
|
||||||
`, roomId, roomId)),
|
`, roomId, roomId)),
|
||||||
),
|
),
|
||||||
|
// Adjusted flex properties for responsive layout
|
||||||
h.Class("flex flex-row min-h-screen bg-neutral-100"),
|
h.Class("flex flex-row h-screen bg-neutral-100 overflow-x-hidden"),
|
||||||
|
// Collapse Button for mobile
|
||||||
|
CollapseButton(),
|
||||||
// Sidebar for connected users
|
// Sidebar for connected users
|
||||||
UserSidebar(),
|
UserSidebar(),
|
||||||
|
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("flex flex-col flex-grow bg-white rounded p-4"),
|
// Adjusted to fill height and width
|
||||||
|
h.Class("flex flex-col h-full w-full bg-white p-4 overflow-hidden"),
|
||||||
// Room name at the top, fixed
|
// Room name at the top, fixed
|
||||||
CachedRoomHeader(ctx),
|
CachedRoomHeader(ctx),
|
||||||
|
|
||||||
// Padding to push chat content below the fixed room name
|
|
||||||
h.Div(h.Class("pt-[50px]")),
|
|
||||||
|
|
||||||
h.HxAfterSseMessage(
|
h.HxAfterSseMessage(
|
||||||
js.EvalJsOnSibling("#messages",
|
js.EvalJsOnSibling("#messages",
|
||||||
`element.scrollTop = element.scrollHeight;`),
|
`element.scrollTop = element.scrollHeight;`),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Chat Messages
|
// Chat Messages
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Id("messages"),
|
h.Id("messages"),
|
||||||
h.Class("flex flex-col gap-4 overflow-auto grow w-full mb-4 max-w-[calc(100%-215px)]"),
|
// Adjusted flex properties and removed max-width
|
||||||
|
h.Class("flex flex-col gap-4 mb-4 overflow-auto flex-grow w-full pt-[50px]"),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Chat Input at the bottom
|
// Chat Input at the bottom
|
||||||
h.Div(
|
|
||||||
h.Class("mt-auto"),
|
|
||||||
Form(),
|
Form(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +80,10 @@ func roomNameHeader(ctx *h.RequestContext) *h.Element {
|
||||||
}
|
}
|
||||||
return h.Div(
|
return h.Div(
|
||||||
h.Class("bg-neutral-700 text-white p-3 shadow-sm w-full fixed top-0 left-0 flex justify-center z-10"),
|
h.Class("bg-neutral-700 text-white p-3 shadow-sm w-full fixed top-0 left-0 flex justify-center z-10"),
|
||||||
h.H2F(room.Name, h.Class("text-lg font-bold")),
|
h.H2F(
|
||||||
|
room.Name,
|
||||||
|
h.Class("text-lg font-bold"),
|
||||||
|
),
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("absolute right-5 top-3 cursor-pointer"),
|
h.Class("absolute right-5 top-3 cursor-pointer"),
|
||||||
h.Text("Share"),
|
h.Text("Share"),
|
||||||
|
|
@ -108,10 +98,13 @@ func roomNameHeader(ctx *h.RequestContext) *h.Element {
|
||||||
|
|
||||||
func UserSidebar() *h.Element {
|
func UserSidebar() *h.Element {
|
||||||
return h.Div(
|
return h.Div(
|
||||||
h.Class("pt-[67px] min-w-48 w-48 bg-neutral-200 p-4 flex flex-col justify-between gap-3 rounded-l-lg"),
|
h.Class("sidebar h-full pt-[67px] min-w-48 w-48 bg-neutral-200 p-4 flex-col justify-between gap-3 rounded-l-lg hidden md:flex"),
|
||||||
h.Div(
|
h.Div(
|
||||||
h.H3F("Connected Users", h.Class("text-lg font-bold")),
|
h.H3F(
|
||||||
chat.ConnectedUsers("", false),
|
"Connected Users",
|
||||||
|
h.Class("text-lg font-bold"),
|
||||||
|
),
|
||||||
|
chat.ConnectedUsers(make([]db.User, 0), ""),
|
||||||
),
|
),
|
||||||
h.A(
|
h.A(
|
||||||
h.Class("cursor-pointer"),
|
h.Class("cursor-pointer"),
|
||||||
|
|
@ -121,8 +114,30 @@ func UserSidebar() *h.Element {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CollapseButton() *h.Element {
|
||||||
|
return h.Div(
|
||||||
|
h.Class("fixed top-0 left-4 md:hidden z-50"),
|
||||||
|
// Always visible on mobile
|
||||||
|
h.Button(
|
||||||
|
h.Class("p-2 text-2xl bg-neutral-700 text-white rounded-md"),
|
||||||
|
// Styling the button
|
||||||
|
h.OnClick(
|
||||||
|
js.EvalJs(`
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
sidebar.classList.toggle('hidden');
|
||||||
|
sidebar.classList.toggle('flex');
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
h.UnsafeRaw("☰"),
|
||||||
|
|
||||||
|
// The icon for collapsing the sidebar
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func MessageInput() *h.Element {
|
func MessageInput() *h.Element {
|
||||||
return h.Input("text",
|
return h.Input(
|
||||||
|
"text",
|
||||||
h.Id("message-input"),
|
h.Id("message-input"),
|
||||||
h.Required(),
|
h.Required(),
|
||||||
h.Class("p-4 rounded-md border border-slate-200 w-full focus:outline-none focus:ring focus:ring-slate-200"),
|
h.Class("p-4 rounded-md border border-slate-200 w-full focus:outline-none focus:ring focus:ring-slate-200"),
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
|
||||||
h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"),
|
h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"),
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"),
|
h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"),
|
||||||
h.H2F("htmgo chat", h.Class("text-3xl font-bold text-center mb-6")),
|
h.H2F(
|
||||||
|
"htmgo chat",
|
||||||
|
h.Class("text-3xl font-bold text-center mb-6"),
|
||||||
|
),
|
||||||
h.Form(
|
h.Form(
|
||||||
h.Attribute("hx-swap", "none"),
|
h.Attribute("hx-swap", "none"),
|
||||||
h.PostPartial(partials.CreateOrJoinRoom),
|
h.PostPartial(partials.CreateOrJoinRoom),
|
||||||
h.Class("flex flex-col gap-6"),
|
h.Class("flex flex-col gap-6"),
|
||||||
|
|
||||||
// Username input at the top
|
// Username input at the top
|
||||||
components.Input(components.InputProps{
|
components.Input(components.InputProps{
|
||||||
Id: "username",
|
Id: "username",
|
||||||
|
|
@ -30,11 +32,9 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
|
||||||
h.MaxLength(15),
|
h.MaxLength(15),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Single box for Create or Join a Chat Room
|
// Single box for Create or Join a Chat Room
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("p-4 border border-gray-300 rounded-md flex flex-col gap-6"),
|
h.Class("p-4 border border-gray-300 rounded-md flex flex-col gap-6"),
|
||||||
|
|
||||||
// Create New Chat Room input
|
// Create New Chat Room input
|
||||||
components.Input(components.InputProps{
|
components.Input(components.InputProps{
|
||||||
Name: "new-chat-room",
|
Name: "new-chat-room",
|
||||||
|
|
@ -45,15 +45,20 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
|
||||||
h.MaxLength(20),
|
h.MaxLength(20),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// OR divider
|
// OR divider
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("flex items-center justify-center gap-4"),
|
h.Class("flex items-center justify-center gap-4"),
|
||||||
h.Div(h.Class("border-t border-gray-300 flex-grow")),
|
h.Div(
|
||||||
h.P(h.Text("OR"), h.Class("text-gray-500")),
|
h.Class("border-t border-gray-300 flex-grow"),
|
||||||
h.Div(h.Class("border-t border-gray-300 flex-grow")),
|
),
|
||||||
|
h.P(
|
||||||
|
h.Text("OR"),
|
||||||
|
h.Class("text-gray-500"),
|
||||||
|
),
|
||||||
|
h.Div(
|
||||||
|
h.Class("border-t border-gray-300 flex-grow"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Join Chat Room input
|
// Join Chat Room input
|
||||||
components.Input(components.InputProps{
|
components.Input(components.InputProps{
|
||||||
Id: "join-chat-room",
|
Id: "join-chat-room",
|
||||||
|
|
@ -67,10 +72,8 @@ func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error message
|
// Error message
|
||||||
components.FormError(""),
|
components.FormError(""),
|
||||||
|
|
||||||
// Submit button at the bottom
|
// Submit button at the bottom
|
||||||
components.PrimaryButton(components.ButtonProps{
|
components.PrimaryButton(components.ButtonProps{
|
||||||
Type: "submit",
|
Type: "submit",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ func RootPage(children ...h.Ren) h.Ren {
|
||||||
extensions := h.BaseExtensions()
|
extensions := h.BaseExtensions()
|
||||||
return h.Html(
|
return h.Html(
|
||||||
h.HxExtension(extensions),
|
h.HxExtension(extensions),
|
||||||
|
h.Meta("viewport", "width=device-width, initial-scale=1"),
|
||||||
|
h.Meta("title", "htmgo chat example"),
|
||||||
|
h.Meta("charset", "utf-8"),
|
||||||
|
h.Meta("author", "htmgo"),
|
||||||
h.Head(
|
h.Head(
|
||||||
h.Link("/public/main.css", "stylesheet"),
|
h.Link("/public/main.css", "stylesheet"),
|
||||||
h.Script("/public/htmgo.js"),
|
h.Script("/public/htmgo.js"),
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@ package partials
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"chat/components"
|
"chat/components"
|
||||||
"chat/ws"
|
"chat/sse"
|
||||||
"github.com/maddalax/htmgo/framework/h"
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
"github.com/maddalax/htmgo/framework/service"
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SendMessage(ctx *h.RequestContext) *h.Partial {
|
func SendMessage(ctx *h.RequestContext) *h.Partial {
|
||||||
locator := ctx.ServiceLocator()
|
locator := ctx.ServiceLocator()
|
||||||
socketManager := service.Get[ws.SocketManager](locator)
|
socketManager := service.Get[sse.SocketManager](locator)
|
||||||
|
|
||||||
sessionCookie, err := ctx.Request.Cookie("session_id")
|
sessionCookie, err := ctx.Request.Cookie("session_id")
|
||||||
|
|
||||||
|
|
|
||||||
112
examples/chat/sse/handler.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handle() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Set the necessary headers
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // Optional for CORS
|
||||||
|
|
||||||
|
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
||||||
|
locator := cc.ServiceLocator()
|
||||||
|
manager := service.Get[SocketManager](locator)
|
||||||
|
|
||||||
|
sessionCookie, _ := r.Cookie("session_id")
|
||||||
|
sessionId := ""
|
||||||
|
|
||||||
|
if sessionCookie != nil {
|
||||||
|
sessionId = sessionCookie.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
/*
|
||||||
|
Large buffer in case the client disconnects while we are writing
|
||||||
|
we don't want to block the writer
|
||||||
|
*/
|
||||||
|
done := make(chan bool, 1000)
|
||||||
|
writer := make(WriterChan, 1000)
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This goroutine is responsible for writing messages to the client
|
||||||
|
*/
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer manager.Disconnect(sessionId)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
fmt.Printf("empting channels\n")
|
||||||
|
for len(writer) > 0 {
|
||||||
|
<-writer
|
||||||
|
}
|
||||||
|
for len(done) > 0 {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-done:
|
||||||
|
fmt.Printf("closing connection: \n")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
manager.Ping(sessionId)
|
||||||
|
case message := <-writer:
|
||||||
|
_, err := fmt.Fprintf(w, message)
|
||||||
|
if err != nil {
|
||||||
|
done <- true
|
||||||
|
} else {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This goroutine is responsible for adding the client to the room
|
||||||
|
*/
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if sessionId == "" {
|
||||||
|
manager.writeCloseRaw(writer, "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roomId := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
if roomId == "" {
|
||||||
|
slog.Error("invalid room", slog.String("room_id", roomId))
|
||||||
|
manager.writeCloseRaw(writer, "invalid room")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.Add(roomId, sessionId, writer, done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
package ws
|
package sse
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"chat/internal/routine"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/puzpuzpuz/xsync/v3"
|
"github.com/puzpuzpuz/xsync/v3"
|
||||||
"net/http"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EventType string
|
type EventType string
|
||||||
|
type WriterChan chan string
|
||||||
|
type DoneChan chan bool
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ConnectedEvent EventType = "connected"
|
ConnectedEvent EventType = "connected"
|
||||||
|
|
@ -28,10 +31,9 @@ type CloseEvent struct {
|
||||||
|
|
||||||
type SocketConnection struct {
|
type SocketConnection struct {
|
||||||
Id string
|
Id string
|
||||||
Writer http.ResponseWriter
|
|
||||||
RoomId string
|
RoomId string
|
||||||
Done chan CloseEvent
|
Done DoneChan
|
||||||
Flush chan bool
|
Writer WriterChan
|
||||||
}
|
}
|
||||||
|
|
||||||
type SocketManager struct {
|
type SocketManager struct {
|
||||||
|
|
@ -62,13 +64,29 @@ func (manager *SocketManager) Listen(listener chan SocketEvent) {
|
||||||
if manager.listeners == nil {
|
if manager.listeners == nil {
|
||||||
manager.listeners = make([]chan SocketEvent, 0)
|
manager.listeners = make([]chan SocketEvent, 0)
|
||||||
}
|
}
|
||||||
|
if listener != nil {
|
||||||
manager.listeners = append(manager.listeners, listener)
|
manager.listeners = append(manager.listeners, listener)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) dispatch(event SocketEvent) {
|
func (manager *SocketManager) dispatch(event SocketEvent) {
|
||||||
|
fmt.Printf("dispatching event: %s\n", event.Type)
|
||||||
|
done := make(chan struct{}, 1)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
fmt.Printf("dispatched event: %s\n", event.Type)
|
||||||
|
return
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
fmt.Printf("havent dispatched event after 5s, chan blocked: %s\n", event.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
for _, listener := range manager.listeners {
|
for _, listener := range manager.listeners {
|
||||||
listener <- event
|
listener <- event
|
||||||
}
|
}
|
||||||
|
done <- struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) OnMessage(id string, message map[string]any) {
|
func (manager *SocketManager) OnMessage(id string, message map[string]any) {
|
||||||
|
|
@ -84,7 +102,7 @@ func (manager *SocketManager) OnMessage(id string, message map[string]any) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) Add(roomId string, id string, writer http.ResponseWriter, done chan CloseEvent, flush chan bool) {
|
func (manager *SocketManager) Add(roomId string, id string, writer WriterChan, done DoneChan) {
|
||||||
manager.idToRoom.Store(id, roomId)
|
manager.idToRoom.Store(id, roomId)
|
||||||
|
|
||||||
sockets, ok := manager.sockets.LoadOrCompute(roomId, func() *xsync.MapOf[string, SocketConnection] {
|
sockets, ok := manager.sockets.LoadOrCompute(roomId, func() *xsync.MapOf[string, SocketConnection] {
|
||||||
|
|
@ -96,7 +114,6 @@ func (manager *SocketManager) Add(roomId string, id string, writer http.Response
|
||||||
Writer: writer,
|
Writer: writer,
|
||||||
RoomId: roomId,
|
RoomId: roomId,
|
||||||
Done: done,
|
Done: done,
|
||||||
Flush: flush,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
s, ok := sockets.Load(id)
|
s, ok := sockets.Load(id)
|
||||||
|
|
@ -110,6 +127,8 @@ func (manager *SocketManager) Add(roomId string, id string, writer http.Response
|
||||||
RoomId: s.RoomId,
|
RoomId: s.RoomId,
|
||||||
Payload: map[string]any{},
|
Payload: map[string]any{},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fmt.Printf("User %s connected to %s\n", id, roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) OnClose(id string) {
|
func (manager *SocketManager) OnClose(id string) {
|
||||||
|
|
@ -126,25 +145,20 @@ func (manager *SocketManager) OnClose(id string) {
|
||||||
manager.sockets.Delete(id)
|
manager.sockets.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) CloseWithError(id string, code int, message string) {
|
func (manager *SocketManager) CloseWithMessage(id string, message string) {
|
||||||
conn := manager.Get(id)
|
conn := manager.Get(id)
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
go manager.OnClose(id)
|
defer manager.OnClose(id)
|
||||||
conn.Done <- CloseEvent{
|
manager.writeText(*conn, "error", message)
|
||||||
Code: code,
|
conn.Done <- true
|
||||||
Reason: message,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) Disconnect(id string) {
|
func (manager *SocketManager) Disconnect(id string) {
|
||||||
conn := manager.Get(id)
|
conn := manager.Get(id)
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
go manager.OnClose(id)
|
manager.OnClose(id)
|
||||||
conn.Done <- CloseEvent{
|
conn.Done <- true
|
||||||
Code: -1,
|
|
||||||
Reason: "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,20 +182,32 @@ func (manager *SocketManager) Ping(id string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) writeCloseRaw(writer WriterChan, message string) {
|
||||||
|
manager.writeTextRaw(writer, "close", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) writeTextRaw(writer WriterChan, event string, message string) {
|
||||||
|
routine.DebugLongRunning("writeTextRaw", func() {
|
||||||
|
timeout := 3 * time.Second
|
||||||
|
data := ""
|
||||||
|
if event != "" {
|
||||||
|
data = fmt.Sprintf("event: %s\ndata: %s\n\n", event, message)
|
||||||
|
} else {
|
||||||
|
data = fmt.Sprintf("data: %s\n\n", message)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case writer <- data:
|
||||||
|
case <-time.After(timeout):
|
||||||
|
fmt.Printf("could not send %s to channel after %s\n", data, timeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) writeText(socket SocketConnection, event string, message string) {
|
func (manager *SocketManager) writeText(socket SocketConnection, event string, message string) {
|
||||||
if socket.Writer == nil {
|
if socket.Writer == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var err error
|
manager.writeTextRaw(socket.Writer, event, message)
|
||||||
if event != "" {
|
|
||||||
_, err = fmt.Fprintf(socket.Writer, "event: %s\ndata: %s\n\n", event, message)
|
|
||||||
} else {
|
|
||||||
_, err = fmt.Fprintf(socket.Writer, "data: %s\n\n", message)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
manager.CloseWithError(socket.Id, 1008, "failed to write message")
|
|
||||||
}
|
|
||||||
socket.Flush <- true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) BroadcastText(roomId string, message string, predicate func(conn SocketConnection) bool) {
|
func (manager *SocketManager) BroadcastText(roomId string, message string, predicate func(conn SocketConnection) bool) {
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/maddalax/htmgo/framework/h"
|
|
||||||
"github.com/maddalax/htmgo/framework/service"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Handle() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
|
||||||
|
|
||||||
sessionCookie, _ := r.Cookie("session_id")
|
|
||||||
|
|
||||||
if sessionCookie == nil {
|
|
||||||
slog.Error("session cookie not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
locator := cc.ServiceLocator()
|
|
||||||
manager := service.Get[SocketManager](locator)
|
|
||||||
|
|
||||||
sessionId := sessionCookie.Value
|
|
||||||
|
|
||||||
roomId := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if roomId == "" {
|
|
||||||
slog.Error("invalid room", slog.String("room_id", roomId))
|
|
||||||
manager.CloseWithError(sessionId, 1008, "invalid room")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
done := make(chan CloseEvent, 50)
|
|
||||||
flush := make(chan bool, 50)
|
|
||||||
|
|
||||||
manager.Add(roomId, sessionId, w, done, flush)
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
manager.Disconnect(sessionId)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Set the necessary headers
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // Optional for CORS
|
|
||||||
|
|
||||||
// Flush the headers immediately
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
manager.Ping(sessionId)
|
|
||||||
case <-flush:
|
|
||||||
if flusher != nil {
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
case <-done: // Client closed the connection
|
|
||||||
fmt.Println("Client disconnected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
examples/hackernews/.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Project exclude paths
|
||||||
|
/tmp/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
js/dist
|
||||||
|
js/node_modules
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
.idea
|
||||||
|
!framework/assets/dist
|
||||||
|
__htmgo
|
||||||
6
examples/hackernews/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/assets/dist
|
||||||
|
tmp
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
__htmgo
|
||||||
|
dist
|
||||||
38
examples/hackernews/Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Stage 1: Build the Go binary
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk update
|
||||||
|
RUN apk add git
|
||||||
|
RUN apk add curl
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go.mod and go.sum files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download and cache the Go modules
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy the source code into the container
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the Go binary for Linux
|
||||||
|
RUN GOPRIVATE=github.com/maddalax GOPROXY=direct go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 2: Create the smallest possible image
|
||||||
|
FROM gcr.io/distroless/base-debian11
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the Go binary from the builder stage
|
||||||
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
|
# Expose the necessary port (replace with your server port)
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|
||||||
|
# Command to run the binary
|
||||||
|
CMD ["./hackernews"]
|
||||||
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
|
||||||
13
examples/hackernews/assets.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//go:build !prod
|
||||||
|
// +build !prod
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hackernews/internal/embedded"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetStaticAssets() fs.FS {
|
||||||
|
return embedded.NewOsFs()
|
||||||
|
}
|
||||||
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 |
16
examples/hackernews/assets_prod.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//go:build prod
|
||||||
|
// +build prod
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/dist/*
|
||||||
|
var staticAssets embed.FS
|
||||||
|
|
||||||
|
func GetStaticAssets() fs.FS {
|
||||||
|
return staticAssets
|
||||||
|
}
|
||||||
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 v1.0.2-0.20241025174132-df3edccd7fb0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
)
|
||||||
16
examples/hackernews/go.sum
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0 h1:K9Q5b7BmbpCPJFjrAHS8+wPdKDcZN9NMC3Fg51n5IaQ=
|
||||||
|
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||||
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
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
|
||||||
|
}
|
||||||
17
examples/hackernews/internal/embedded/os.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package embedded
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OsFs struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (receiver OsFs) Open(name string) (fs.File, error) {
|
||||||
|
return os.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOsFs() OsFs {
|
||||||
|
return OsFs{}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
13
examples/hackernews/internal/random.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import "math/rand"
|
||||||
|
|
||||||
|
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
|
|
||||||
|
func RandSeq(n int) string {
|
||||||
|
b := make([]rune, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = letters[rand.Intn(len(letters))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
41
examples/hackernews/pages/root.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RootPage(children ...h.Ren) h.Ren {
|
||||||
|
banner := h.A(
|
||||||
|
h.Class("bg-neutral-200 text-neutral-600 text-center p-2 flex items-center justify-center"),
|
||||||
|
h.Href("https://github.com/maddalax/htmgo"),
|
||||||
|
h.Attribute("target", "_blank"),
|
||||||
|
h.Text("Built with htmgo.dev"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return h.Html(
|
||||||
|
h.HxExtensions(
|
||||||
|
h.BaseExtensions(),
|
||||||
|
),
|
||||||
|
h.Head(
|
||||||
|
h.Meta("viewport", "width=device-width, initial-scale=1"),
|
||||||
|
h.Link("/public/favicon.ico", "icon"),
|
||||||
|
h.Link("/public/apple-touch-icon.png", "apple-touch-icon"),
|
||||||
|
h.Meta("title", "hackernews"),
|
||||||
|
h.Meta("charset", "utf-8"),
|
||||||
|
h.Meta("author", "htmgo"),
|
||||||
|
h.Meta("description", "hacker news reader, built with htmgo"),
|
||||||
|
h.Meta("og:title", "hacker news reader"),
|
||||||
|
h.Meta("og:url", "https://hn.htmgo.dev"),
|
||||||
|
h.Link("canonical", "https://hn.htmgo.dev"),
|
||||||
|
h.Meta("og:description", "hacker news reader, built with htmgo"),
|
||||||
|
h.Link("/public/main.css", "stylesheet"),
|
||||||
|
h.Script("/public/htmgo.js"),
|
||||||
|
),
|
||||||
|
h.Body(
|
||||||
|
banner,
|
||||||
|
h.Div(
|
||||||
|
h.Fragment(children...),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
102
examples/hackernews/partials/comments.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package partials
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"hackernews/internal/batch"
|
||||||
|
"hackernews/internal/news"
|
||||||
|
"hackernews/internal/timeformat"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StoryComments(ctx *h.RequestContext) *h.Partial {
|
||||||
|
return h.NewPartial(
|
||||||
|
h.Fragment(
|
||||||
|
h.OobSwap(
|
||||||
|
ctx,
|
||||||
|
h.Div(
|
||||||
|
h.Id("comments-loader"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
h.Div(
|
||||||
|
h.Class("flex flex-col gap-3 prose max-w-none"),
|
||||||
|
CachedStoryComments(news.MustItemId(ctx)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var CachedStoryComments = h.CachedPerKeyT[string, int](time.Minute*3, func(itemId int) (string, h.GetElementFunc) {
|
||||||
|
return fmt.Sprintf("story-comments-%d", itemId), func() *h.Element {
|
||||||
|
story, err := news.GetStory(itemId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return h.Div(
|
||||||
|
h.Text("Failed to load story"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
comments := news.GetComments(story.Kids)
|
||||||
|
|
||||||
|
// parallel process because each comment needs to load its children comments
|
||||||
|
items := batch.ParallelProcess[news.Comment, *h.Element](comments, 50, func(item news.Comment) *h.Element {
|
||||||
|
return Comment(item, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
return h.List(items, func(item *h.Element, index int) *h.Element {
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
func Comment(item news.Comment, nesting int) *h.Element {
|
||||||
|
if item.Text == "" {
|
||||||
|
return h.Empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
children := news.GetComments(item.Kids)
|
||||||
|
|
||||||
|
return h.Div(
|
||||||
|
h.ClassX("block bg-white pb-2 pt-2", h.ClassMap{
|
||||||
|
"border-b border-gray-200": nesting == 0,
|
||||||
|
"border-l border-gray-200": nesting > 0,
|
||||||
|
}),
|
||||||
|
h.If(
|
||||||
|
nesting > 0,
|
||||||
|
h.Attribute("style", fmt.Sprintf("margin-left: %dpx", (nesting-1)*15)),
|
||||||
|
),
|
||||||
|
h.Div(
|
||||||
|
h.If(
|
||||||
|
nesting > 0,
|
||||||
|
h.Class("pl-4"),
|
||||||
|
),
|
||||||
|
h.Div(
|
||||||
|
h.Class("flex gap-1 items-center"),
|
||||||
|
h.Div(
|
||||||
|
h.Class("font-bold text-rose-500"),
|
||||||
|
h.UnsafeRaw(item.By),
|
||||||
|
),
|
||||||
|
h.Div(
|
||||||
|
h.Class("text-sm text-gray-600"),
|
||||||
|
h.UnsafeRaw("•"),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
164
examples/hackernews/partials/sidebar.go
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
package partials
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"hackernews/components"
|
||||||
|
"hackernews/internal/news"
|
||||||
|
"hackernews/internal/parse"
|
||||||
|
"hackernews/internal/timeformat"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ScrollJs = `
|
||||||
|
const scrollContainer = self;
|
||||||
|
let isDown = false;
|
||||||
|
let startX;
|
||||||
|
let scrollLeft;
|
||||||
|
|
||||||
|
scrollContainer.addEventListener("mousedown", (e) => {
|
||||||
|
isDown = true;
|
||||||
|
scrollContainer.classList.add("active");
|
||||||
|
startX = e.pageX - scrollContainer.offsetLeft;
|
||||||
|
scrollLeft = scrollContainer.scrollLeft;
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollContainer.addEventListener("mouseleave", () => {
|
||||||
|
isDown = false;
|
||||||
|
scrollContainer.classList.remove("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollContainer.addEventListener("mouseup", () => {
|
||||||
|
isDown = false;
|
||||||
|
scrollContainer.classList.remove("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollContainer.addEventListener("mousemove", (e) => {
|
||||||
|
if (!isDown) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const x = e.pageX - scrollContainer.offsetLeft;
|
||||||
|
const walk = (x - startX) * 3; // Adjust scroll speed here
|
||||||
|
scrollContainer.scrollLeft = scrollLeft - walk;
|
||||||
|
});
|
||||||
|
`
|
||||||
|
|
||||||
|
func StorySidebar(ctx *h.RequestContext) *h.Partial {
|
||||||
|
category := h.GetQueryParam(ctx, "category")
|
||||||
|
pageRaw := h.GetQueryParam(ctx, "page")
|
||||||
|
mode := h.GetQueryParam(ctx, "mode")
|
||||||
|
|
||||||
|
if pageRaw == "" {
|
||||||
|
pageRaw = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
if category == "" {
|
||||||
|
category = "topstories"
|
||||||
|
}
|
||||||
|
|
||||||
|
page := parse.MustParseInt(pageRaw, 0)
|
||||||
|
|
||||||
|
fetchMorePath := h.GetPartialPathWithQs(
|
||||||
|
StorySidebar,
|
||||||
|
h.NewQs("mode", "infinite", "page", fmt.Sprintf("%d", page+1), "category", category),
|
||||||
|
)
|
||||||
|
|
||||||
|
list := CachedStoryList(category, page, 50, fetchMorePath)
|
||||||
|
|
||||||
|
body := h.Aside(
|
||||||
|
h.Id("story-sidebar"),
|
||||||
|
h.JoinExtensions(
|
||||||
|
h.TriggerChildren(),
|
||||||
|
),
|
||||||
|
h.Class("sticky top-0 h-screen p-1 bg-gray-100 overflow-y-auto max-w-80 min-w-80"),
|
||||||
|
h.Div(
|
||||||
|
h.Class("flex flex-col gap-1"),
|
||||||
|
SidebarTitle(category),
|
||||||
|
h.Id("story-list"),
|
||||||
|
list,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == "infinite" {
|
||||||
|
return h.NewPartial(
|
||||||
|
list,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.IsHxRequest() {
|
||||||
|
return h.SwapManyPartial(ctx, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.NewPartial(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SidebarTitle(defaultCategory string) *h.Element {
|
||||||
|
today := time.Now().Format("Mon, 02 Jan 2006")
|
||||||
|
return h.Div(
|
||||||
|
h.Class("flex flex-col px-2 pt-4 pb-2"),
|
||||||
|
h.Div(
|
||||||
|
h.Class("text-sm text-gray-600"),
|
||||||
|
h.Text(today),
|
||||||
|
),
|
||||||
|
h.Div(
|
||||||
|
h.Class("font-bold text-xl"),
|
||||||
|
h.Text("Hacker News"),
|
||||||
|
),
|
||||||
|
h.Div(
|
||||||
|
h.OnLoad(
|
||||||
|
h.EvalJs(ScrollJs),
|
||||||
|
),
|
||||||
|
h.Class("scroll-container mt-2 flex gap-1 no-scrollbar overflow-y-hidden whitespace-nowrap overflow-x-auto"),
|
||||||
|
h.List(news.Categories, func(item news.Category, index int) *h.Element {
|
||||||
|
return CategoryBadge(defaultCategory, item)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CategoryBadge(defaultCategory string, category news.Category) *h.Element {
|
||||||
|
selected := category.Path == defaultCategory
|
||||||
|
return components.Badge(
|
||||||
|
category.Name,
|
||||||
|
selected,
|
||||||
|
h.Attribute("hx-swap", "none"),
|
||||||
|
h.If(
|
||||||
|
!selected,
|
||||||
|
h.PostPartialOnClickQs(
|
||||||
|
StorySidebar,
|
||||||
|
h.NewQs("category", category.Path),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var CachedStoryList = h.CachedPerKeyT4(time.Minute*5, func(category string, page int, limit int, fetchMorePath string) (string, h.GetElementFunc) {
|
||||||
|
return fmt.Sprintf("%s-stories-%d-%d", category, page, limit), func() *h.Element {
|
||||||
|
stories := news.GetStories(category, page, limit)
|
||||||
|
return h.List(stories, func(item news.Story, index int) *h.Element {
|
||||||
|
return h.Div(
|
||||||
|
h.Attribute("hx-swap", "none"),
|
||||||
|
h.PostPartialOnClickQs(Story, h.NewQs("item", fmt.Sprintf("%d", item.Id))),
|
||||||
|
h.A(h.Href(item.Url)),
|
||||||
|
h.Class("block p-2 bg-white rounded-md shadow cursor-pointer"),
|
||||||
|
h.Div(
|
||||||
|
h.Class("font-bold"),
|
||||||
|
h.UnsafeRaw(item.Title),
|
||||||
|
),
|
||||||
|
h.Div(
|
||||||
|
h.Class("text-sm text-gray-600"),
|
||||||
|
h.Div(h.TextF("%s ", item.By), h.UnsafeRaw("•"), 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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
7
examples/hackernews/tailwind.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["**/*.go"],
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/typography')
|
||||||
|
],
|
||||||
|
};
|
||||||
11
examples/simple-auth/.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Project exclude paths
|
||||||
|
/tmp/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
js/dist
|
||||||
|
js/node_modules
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
.idea
|
||||||
|
!framework/assets/dist
|
||||||
|
__htmgo
|
||||||
6
examples/simple-auth/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/assets/dist
|
||||||
|
tmp
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
__htmgo
|
||||||
|
dist
|
||||||
36
examples/simple-auth/Dockerfile
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Stage 1: Build the Go binary
|
||||||
|
FROM golang:1.23 AS builder
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go.mod and go.sum files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download and cache the Go modules
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy the source code into the container
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the Go binary for Linux
|
||||||
|
RUN CGO_ENABLED=0 GOPRIVATE=github.com/maddalax LOG_LEVEL=debug go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build -tags prod -o ./dist -a -ldflags '-linkmode external -extldflags "-static"' .
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 2: Create the smallest possible image
|
||||||
|
FROM gcr.io/distroless/base-debian11
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the Go binary from the builder stage
|
||||||
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
|
# Expose the necessary port (replace with your server port)
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|
||||||
|
# Command to run the binary
|
||||||
|
CMD ["./simpleauth"]
|
||||||
20
examples/simple-auth/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
|
||||||
13
examples/simple-auth/assets.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//go:build !prod
|
||||||
|
// +build !prod
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"simpleauth/internal/embedded"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetStaticAssets() fs.FS {
|
||||||
|
return embedded.NewOsFs()
|
||||||
|
}
|
||||||
3
examples/simple-auth/assets/css/input.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
BIN
examples/simple-auth/assets/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
examples/simple-auth/assets/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
examples/simple-auth/assets/public/icon-192-maskable.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
examples/simple-auth/assets/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
examples/simple-auth/assets/public/icon-512-maskable.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
examples/simple-auth/assets/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
16
examples/simple-auth/assets_prod.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//go:build prod
|
||||||
|
// +build prod
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/dist/*
|
||||||
|
var staticAssets embed.FS
|
||||||
|
|
||||||
|
func GetStaticAssets() fs.FS {
|
||||||
|
return staticAssets
|
||||||
|
}
|
||||||
14
examples/simple-auth/go.mod
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
module simpleauth
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
|
golang.org/x/crypto v0.28.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
)
|
||||||
20
examples/simple-auth/go.sum
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0 h1:K9Q5b7BmbpCPJFjrAHS8+wPdKDcZN9NMC3Fg51n5IaQ=
|
||||||
|
github.com/maddalax/htmgo/framework v1.0.2-0.20241025174132-df3edccd7fb0/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||||
|
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||||
|
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||||
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
10
examples/simple-auth/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"]
|
||||||
31
examples/simple-auth/internal/db/db.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.27.0
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||||
|
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||||
|
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||||
|
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db DBTX) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
db: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
26
examples/simple-auth/internal/db/models.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.27.0
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
SessionID string
|
||||||
|
CreatedAt sql.NullString
|
||||||
|
ExpiresAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
Metadata interface{}
|
||||||
|
CreatedAt sql.NullString
|
||||||
|
UpdatedAt sql.NullString
|
||||||
|
}
|
||||||
25
examples/simple-auth/internal/db/provider.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed schema.sql
|
||||||
|
var ddl string
|
||||||
|
|
||||||
|
func Provide() *Queries {
|
||||||
|
db, err := sql.Open("sqlite3", "file:htmgo-user-example.db?cache=shared&_fk=1")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(context.Background(), ddl); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return New(db)
|
||||||
|
}
|
||||||
31
examples/simple-auth/internal/db/queries.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- Queries for User Management
|
||||||
|
|
||||||
|
-- name: CreateUser :one
|
||||||
|
INSERT INTO user (email, password, metadata)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
RETURNING id;
|
||||||
|
|
||||||
|
-- name: CreateSession :exec
|
||||||
|
INSERT INTO sessions (user_id, session_id, expires_at)
|
||||||
|
VALUES (?, ?, ?);
|
||||||
|
|
||||||
|
-- name: GetUserByToken :one
|
||||||
|
SELECT u.*
|
||||||
|
FROM user u
|
||||||
|
JOIN sessions t ON u.id = t.user_id
|
||||||
|
WHERE t.session_id = ?
|
||||||
|
AND t.expires_at > datetime('now');
|
||||||
|
|
||||||
|
-- name: GetUserByID :one
|
||||||
|
SELECT *
|
||||||
|
FROM user
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
|
||||||
|
-- name: GetUserByEmail :one
|
||||||
|
SELECT *
|
||||||
|
FROM user
|
||||||
|
WHERE email = ?;
|
||||||
|
|
||||||
|
-- name: UpdateUserMetadata :exec
|
||||||
|
UPDATE user SET metadata = json_patch(COALESCE(metadata, '{}'), ?) WHERE id = ?;
|
||||||
123
examples/simple-auth/internal/db/queries.sql.go
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.27.0
|
||||||
|
// source: queries.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createSession = `-- name: CreateSession :exec
|
||||||
|
INSERT INTO sessions (user_id, session_id, expires_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateSessionParams struct {
|
||||||
|
UserID int64
|
||||||
|
SessionID string
|
||||||
|
ExpiresAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createSession, arg.UserID, arg.SessionID, arg.ExpiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUser = `-- name: CreateUser :one
|
||||||
|
|
||||||
|
INSERT INTO user (email, password, metadata)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserParams struct {
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
Metadata interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries for User Management
|
||||||
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createUser, arg.Email, arg.Password, arg.Metadata)
|
||||||
|
var id int64
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||||
|
SELECT id, email, password, metadata, created_at, updated_at
|
||||||
|
FROM user
|
||||||
|
WHERE email = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserByEmail, email)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.Password,
|
||||||
|
&i.Metadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserByID = `-- name: GetUserByID :one
|
||||||
|
SELECT id, email, password, metadata, created_at, updated_at
|
||||||
|
FROM user
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserByID, id)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.Password,
|
||||||
|
&i.Metadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserByToken = `-- name: GetUserByToken :one
|
||||||
|
SELECT u.id, u.email, u.password, u.metadata, u.created_at, u.updated_at
|
||||||
|
FROM user u
|
||||||
|
JOIN sessions t ON u.id = t.user_id
|
||||||
|
WHERE t.session_id = ?
|
||||||
|
AND t.expires_at > datetime('now')
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByToken(ctx context.Context, sessionID string) (User, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserByToken, sessionID)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.Password,
|
||||||
|
&i.Metadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateUserMetadata = `-- name: UpdateUserMetadata :exec
|
||||||
|
UPDATE user SET metadata = json_patch(COALESCE(metadata, '{}'), ?) WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateUserMetadataParams struct {
|
||||||
|
JsonPatch interface{}
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateUserMetadata(ctx context.Context, arg UpdateUserMetadataParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateUserMetadata, arg.JsonPatch, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
28
examples/simple-auth/internal/db/schema.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- SQLite schema for User Management
|
||||||
|
|
||||||
|
-- User table
|
||||||
|
CREATE TABLE IF NOT EXISTS user
|
||||||
|
(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
metadata JSON DEFAULT '{}',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Auth Token table
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions
|
||||||
|
(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes to improve query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_email ON user (email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_id ON sessions (session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id ON sessions (user_id);
|
||||||
17
examples/simple-auth/internal/embedded/os.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package embedded
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OsFs struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (receiver OsFs) Open(name string) (fs.File, error) {
|
||||||
|
return os.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOsFs() OsFs {
|
||||||
|
return OsFs{}
|
||||||
|
}
|
||||||
115
examples/simple-auth/internal/user/handler.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
"simpleauth/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateUserRequest struct {
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginUserRequest struct {
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatedUser struct {
|
||||||
|
Id string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Create(ctx *h.RequestContext, request CreateUserRequest) (int64, error) {
|
||||||
|
if len(request.Password) < 6 {
|
||||||
|
return 0, errors.New("password must be at least 6 characters long")
|
||||||
|
}
|
||||||
|
|
||||||
|
queries := service.Get[db.Queries](ctx.ServiceLocator())
|
||||||
|
|
||||||
|
hashedPassword, err := HashPassword(request.Password)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.New("something went wrong")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := queries.CreateUser(context.Background(), db.CreateUserParams{
|
||||||
|
Email: request.Email,
|
||||||
|
Password: hashedPassword,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
if err.Error() == "UNIQUE constraint failed: user.email" {
|
||||||
|
return 0, errors.New("email already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Login(ctx *h.RequestContext, request LoginUserRequest) (int64, error) {
|
||||||
|
|
||||||
|
queries := service.Get[db.Queries](ctx.ServiceLocator())
|
||||||
|
|
||||||
|
user, err := queries.GetUserByEmail(context.Background(), request.Email)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: %s\n", err.Error())
|
||||||
|
return 0, errors.New("email or password is incorrect")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !PasswordMatches(request.Password, user.Password) {
|
||||||
|
return 0, errors.New("email or password is incorrect")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := CreateSession(ctx, user.ID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.New("something went wrong")
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSessionCookie(ctx, session)
|
||||||
|
|
||||||
|
return user.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMeta(meta any) map[string]interface{} {
|
||||||
|
if meta == nil {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
if m, ok := meta.(string); ok {
|
||||||
|
var dest map[string]interface{}
|
||||||
|
json.Unmarshal([]byte(m), &dest)
|
||||||
|
return dest
|
||||||
|
}
|
||||||
|
return meta.(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMetaKey(meta map[string]interface{}, key string) string {
|
||||||
|
if val, ok := meta[key]; ok {
|
||||||
|
return val.(string)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetMeta(ctx *h.RequestContext, userId int64, meta map[string]interface{}) error {
|
||||||
|
queries := service.Get[db.Queries](ctx.ServiceLocator())
|
||||||
|
serialized, _ := json.Marshal(meta)
|
||||||
|
fmt.Printf("serialized: %s\n", string(serialized))
|
||||||
|
err := queries.UpdateUserMetadata(context.Background(), db.UpdateUserMetadataParams{
|
||||||
|
JsonPatch: serialized,
|
||||||
|
ID: userId,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
17
examples/simple-auth/internal/user/http.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"simpleauth/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetUserOrRedirect(ctx *h.RequestContext) (db.User, bool) {
|
||||||
|
user, err := GetUserFromSession(ctx)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
ctx.Redirect("/login", 302)
|
||||||
|
return db.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
18
examples/simple-auth/internal/user/password.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashedPassword), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasswordMatches(password string, hashedPassword string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||