Compare commits

..

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

501 changed files with 4432 additions and 32057 deletions

51
.air.toml Normal file
View file

@ -0,0 +1,51 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules", "js"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

3
.github/FUNDING.yml vendored
View file

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

View file

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

View file

@ -1,50 +0,0 @@
name: Build and Deploy htmgo.dev chat 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/chat/**' # Trigger only if files in this directory change
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/chat && docker build -t ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:${{ steps.vars.outputs.short_sha }} .
- name: Tag as latest Docker image
run: |
docker tag ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push Docker image
run: |
docker push ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:latest

View file

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

View file

@ -1,52 +0,0 @@
name: Build and Deploy htmgo.dev
on:
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:
- master # Trigger on pushes to master
paths:
- 'htmgo-site/**' # Trigger only if files in this directory change
- "framework-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 ./htmgo-site && docker build -t ghcr.io/${{ github.repository_owner }}/htmgo-site:${{ steps.vars.outputs.short_sha }} .
- name: Tag as latest Docker image
run: |
docker tag ghcr.io/${{ github.repository_owner }}/htmgo-site:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/htmgo-site: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 }}/htmgo-site:latest

View file

@ -1,50 +0,0 @@
name: Build and Deploy starter template
on:
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:
- master # Trigger on pushes to master
paths:
- 'templates/starter/**' # Trigger only if files in this directory change
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 ./templates/starter && docker build -t ghcr.io/${{ github.repository_owner }}/starter-template:${{ steps.vars.outputs.short_sha }} .
- name: Tag as latest Docker image
run: |
docker tag ghcr.io/${{ github.repository_owner }}/starter-template:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/starter-template: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 }}/starter-template:latest

View file

@ -1,50 +0,0 @@
name: Build and Deploy htmgo.dev todo example
on:
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:
- master # Trigger on pushes to master
paths:
- 'examples/todo-list/**' # Trigger only if files in this directory change
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/todo-list && docker build -t ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:${{ steps.vars.outputs.short_sha }} .
- name: Tag as latest Docker image
run: |
docker tag ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push Docker image
run: |
docker push ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:latest

View file

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

View file

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

View file

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

View file

@ -1,39 +0,0 @@
name: Update HTMGO Framework Dependency
on:
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:
- master # Trigger on pushes to master
paths:
- 'framework/**'
- 'tools/html-to-htmgo/**'
jobs:
update-htmgo-dep:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
persist-credentials: false # Necessary to avoid using the runner's credentials for commit
fetch-depth: 0 # Full history for committing back changes
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ">=1.20"
- name: Run update-htmgo-dep.go script
run: go run tools/update-htmgo-dep.go
- name: Commit changes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Auto-update HTMGO framework version"
git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,65 +0,0 @@
name: Build and Verify Installer Works
on:
workflow_dispatch:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
# Step 1: Checkout the repository
- name: Checkout code
uses: actions/checkout@v3
# Step 2: Set up Go
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '>=1.20'
# Step 3: Install htmgo CLI
- name: Install htmgo CLI
run: |
GOPRIVATE=github.com/maddalax GOPROXY=direct go install github.com/maddalax/htmgo/cli/htmgo@latest
# Step 4: Generate template using htmgo
- name: Generate myapp template
run: |
htmgo template myapp
# Step 5: Build the app
- name: Build myapp
run: |
cd myapp
htmgo build
# Step 6: Verify that the dist directory exists
- name: Verify build output
run: |
if [ ! -d "./myapp/dist" ]; then
echo "Build directory ./dist/myapp does not exist"
exit 1
fi
shell: bash
# Step 7: Start the server
- name: Start myapp server
run: |
nohup ./myapp/dist/myapp &
# Step 8: Wait for server to start
- name: Wait for server startup
run: sleep 5
# Step 9: Send curl request to verify the server is running
- name: Test server with curl
run: |
curl --fail http://localhost:3000 || exit 1

7
.gitignore vendored
View file

@ -3,9 +3,4 @@
node_modules/ node_modules/
dist/ dist/
js/dist js/dist
js/node_modules js/node_modules
go.work
go.work.sum
.idea
!framework/assets/dist
/**/__htmgo

5
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="dataSourceStorageLocal" created-in="GO-242.21829.220">
<data-source name="site.db" uuid="6b63d5bd-e451-4904-b659-21db5c54c16d">
<database-info product="SQLite" version="3.40.1" jdbc-version="4.2" driver-name="SQLite JDBC" driver-version="3.40.1.0" dbms="SQLITE" exact-version="3.40.1" exact-driver-version="3.40">
<identifier-quote-string>&quot;</identifier-quote-string>
</database-info>
<case-sensitivity plain-identifiers="mixed" quoted-identifiers="mixed" />
<secret-storage>master_key</secret-storage>
<auth-provider>no-auth</auth-provider>
<schema-mapping>
<introspection-scope>
<node kind="schema" qname="@" />
</introspection-scope>
</schema-mapping>
</data-source>
</component>
</project>

12
.idea/dataSources.xml Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="site.db" uuid="6b63d5bd-e451-4904-b659-21db5c54c16d">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/site.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="x:onclick" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
<inspection_tool class="XmlUnboundNsPrefix" enabled="true" level="INFORMATION" enabled_by_default="true" />
</profile>
</component>

13
.idea/mhtml.iml Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/mhtml.iml" filepath="$PROJECT_DIR$/.idea/mhtml.iml" />
</modules>
</component>
</project>

4
.idea/watcherTasks.xml Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="Pug/Jade" />
</project>

View file

@ -1,128 +0,0 @@
# 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.

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 maddalax
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,50 +0,0 @@
## **htmgo**
### build simple and scalable systems with go + htmx
-------
[![Go Report Card](https://goreportcard.com/badge/github.com/maddalax/htmgo)](https://goreportcard.com/report/github.com/maddalax/htmgo)
![Build](https://github.com/maddalax/htmgo/actions/workflows/run-framework-tests.yml/badge.svg)
[![Go Reference](https://pkg.go.dev/badge/github.com/maddalax/htmgo/framework@v1.0.2/h.svg)](https://htmgo.dev/docs)
[![codecov](https://codecov.io/github/maddalax/htmgo/graph/badge.svg?token=ANPD11LSGN)](https://codecov.io/github/maddalax/htmgo)
[![Join Discord](https://img.shields.io/badge/Join%20Discord-gray?style=flat&logo=discord&logoColor=white&link=https://htmgo.dev/discord)](https://htmgo.dev/discord)
![GitHub Sponsors](https://img.shields.io/github/sponsors/maddalax)
<sup>looking for a python version? check out: https://fastht.ml</sup>
**introduction:**
htmgo is a lightweight pure go way to build interactive websites / web applications using go & htmx.
By combining the speed & simplicity of go + hypermedia attributes ([htmx](https://htmx.org)) to add interactivity to websites, all conveniently wrapped in pure go, you can build simple, fast, interactive websites without touching javascript. All compiled to a **single deployable binary**.
```go
func IndexPage(ctx *h.RequestContext) *h.Page {
now := time.Now()
return h.NewPage(
h.Div(
h.Class("flex gap-2"),
h.TextF("the current time is %s", now.String())
)
)
}
```
**core features:**
1. deployable single binary
2. live reload (rebuilds css, go, ent schema, and routes upon change)
3. automatic page and partial registration based on file path
4. built in tailwindcss support, no need to configure anything by default
5. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
**get started:**
View documentation on [htmgo.dev](https://htmgo.dev/docs).
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=maddalax/htmgo&type=Date)](https://star-history.com/#maddalax/htmgo&Date)

View file

@ -0,0 +1,14 @@
const {join} = require("node:path");
/** @type {import('tailwindcss').Config} */
const root = join(__dirname, "../../");
const content = join(root, "**/*.go");
console.log(content)
module.exports = {
content: [content],
theme: {
extend: {},
},
plugins: [],
};

View file

@ -4,9 +4,9 @@ htmx.defineExtension("debug", {
// @ts-ignore // @ts-ignore
onEvent: function (name, evt) { onEvent: function (name, evt) {
if (console.debug) { if (console.debug) {
console.debug(name, evt); console.debug(name);
} else if (console) { } else if (console) {
console.log("DEBUG:", name, evt); console.log("DEBUG:", name);
} else { } else {
// noop // noop
} }

View file

@ -12,10 +12,8 @@ htmx.defineExtension("mutation-error", {
} }
const status = evt.detail.xhr.status; const status = evt.detail.xhr.status;
if (status >= 400) { if (status >= 400) {
document.querySelectorAll("*").forEach((element) => { htmx.findAll("[hx-on\\:\\:mutation-error]").forEach((element) => {
if (element.hasAttribute("hx-on::on-mutation-error")) { htmx.trigger(element, "htmx:mutation-error", { status });
htmx.trigger(element, "htmx:on-mutation-error", { status });
}
}); });
} }
} }

View file

@ -0,0 +1,43 @@
import htmx, { HtmxSettleInfo, HtmxSwapStyle } from "htmx.org";
htmx.defineExtension("trigger-children", {
onEvent: (name, evt: Event | CustomEvent) => {
if (!(evt instanceof CustomEvent)) {
return false;
}
const target = evt.detail.target as HTMLElement;
if (target && target.children) {
Array.from(target.children).forEach((e) => {
htmx.trigger(e, name, null);
});
}
return true;
},
init: function (api: any): void {},
transformResponse: function (
text: string,
xhr: XMLHttpRequest,
elt: Element,
): string {
return text;
},
isInlineSwap: function (swapStyle: HtmxSwapStyle): boolean {
return false;
},
handleSwap: function (
swapStyle: HtmxSwapStyle,
target: Node,
fragment: Node,
settleInfo: HtmxSettleInfo,
): boolean | Node[] {
return false;
},
encodeParameters: function (
xhr: XMLHttpRequest,
parameters: FormData,
elt: Node,
) {},
getSelectors: function (): string[] | null {
return null;
},
});

View file

@ -1,17 +1,9 @@
import htmx from "htmx.org"; import htmx from "htmx.org";
import "./htmxextensions/pathdeps"; import "./extensions/pathdeps";
import "./htmxextensions/trigger-children"; import "./extensions/trigger-children";
import "./htmxextensions/debug"; import "./extensions/debug";
import "./htmxextensions/response-targets"; import "./extensions/response-targets";
import "./htmxextensions/mutation-error"; import "./extensions/mutation-error";
import "./htmxextensions/livereload"
import "./htmxextensions/htmgo";
import "./htmxextensions/sse"
import "./htmxextensions/ws"
import "./htmxextensions/ws-event-handler"
// @ts-ignore
window.htmx = htmx;
function watchUrl(callback: (oldUrl: string, newUrl: string) => void) { function watchUrl(callback: (oldUrl: string, newUrl: string) => void) {
let lastUrl = window.location.href; let lastUrl = window.location.href;
@ -20,7 +12,7 @@ function watchUrl(callback: (oldUrl: string, newUrl: string) => void) {
callback(lastUrl, window.location.href); callback(lastUrl, window.location.href);
lastUrl = window.location.href; lastUrl = window.location.href;
} }
}, 101); }, 100);
} }
watchUrl((_, newUrl) => { watchUrl((_, newUrl) => {
@ -46,6 +38,7 @@ function onUrlChange(newUrl: string) {
for (let [key, values] of url.searchParams) { for (let [key, values] of url.searchParams) {
let eventName = "qs:" + key; let eventName = "qs:" + key;
if (triggers.includes(eventName)) { if (triggers.includes(eventName)) {
console.log("triggering", eventName);
htmx.trigger(element, eventName, null); htmx.trigger(element, eventName, null);
break; break;
} }
@ -82,17 +75,3 @@ function onUrlChange(newUrl: string) {
} }
}); });
} }
/*
400s should allow swapping by default, as it's useful to show error messages
*/
document.addEventListener('htmx:beforeSwap', function(evt) {
if(evt instanceof CustomEvent) {
// Allow 422 and 400 responses to swap
// We treat these as form validation errors
if (evt.detail.xhr.status === 422 || evt.detail.xhr.status === 400) {
evt.detail.shouldSwap = true;
evt.detail.isError = false;
}
}
});

View file

@ -1,11 +1,11 @@
{ {
"name": "htmgo-js", "name": "mhtml-js",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "htmgo-js", "name": "mhtml-js",
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@ -15,7 +15,6 @@
"@swc/core": "^1.7.26", "@swc/core": "^1.7.26",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"shiki": "^1.17.6",
"tailwindcss": "^3.4.11", "tailwindcss": "^3.4.11",
"tsup": "^8.2.4", "tsup": "^8.2.4",
"typescript": "^5.6.2" "typescript": "^5.6.2"
@ -735,56 +734,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@shikijs/core": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.17.6.tgz",
"integrity": "sha512-9ztslig6/YmCg/XwESAXbKjAjOhaq6HVced9NY6qcbDz1X5g/S90Wco2vMjBNX/6V71ASkzri76JewSGPa7kiQ==",
"dev": true,
"dependencies": {
"@shikijs/engine-javascript": "1.17.6",
"@shikijs/engine-oniguruma": "1.17.6",
"@shikijs/types": "1.17.6",
"@shikijs/vscode-textmate": "^9.2.2",
"@types/hast": "^3.0.4",
"hast-util-to-html": "^9.0.2"
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.17.6.tgz",
"integrity": "sha512-5EEZj8tVcierNxm4V0UMS2PVoflb0UJPalWWV8l9rRg+oOfnr5VivqBJbkyq5grltVPvByIXvVbY8GSM/356jQ==",
"dev": true,
"dependencies": {
"@shikijs/types": "1.17.6",
"oniguruma-to-js": "0.4.3"
}
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.17.6.tgz",
"integrity": "sha512-NLfWDMXFYe0nDHFbEoyZdz89aIIey3bTfF3zLYSUNTXks5s4uinZVmuPOFf1HfTeGqIn8uErJSBc3VnpJO7Alw==",
"dev": true,
"dependencies": {
"@shikijs/types": "1.17.6",
"@shikijs/vscode-textmate": "^9.2.2"
}
},
"node_modules/@shikijs/types": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.17.6.tgz",
"integrity": "sha512-ndTFa2TJi2w51ddKQDn3Jy8f6K4E5Q2x3dA3Hmsd3+YmxDQ10UWHjcw7VbVbKzv3VcUvYPLy+z9neqytSzUMUg==",
"dev": true,
"dependencies": {
"@shikijs/vscode-textmate": "^9.2.2",
"@types/hast": "^3.0.4"
}
},
"node_modules/@shikijs/vscode-textmate": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-9.2.2.tgz",
"integrity": "sha512-TMp15K+GGYrWlZM8+Lnj9EaHEFmOen0WJBrfa17hF7taDOYthuPPV0GWzfd/9iMij0akS/8Yw2ikquH7uVi/fg==",
"dev": true
},
"node_modules/@swc/core": { "node_modules/@swc/core": {
"version": "1.7.26", "version": "1.7.26",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.26.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.26.tgz",
@ -1004,24 +953,6 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true "dev": true
}, },
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"dev": true,
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"dev": true,
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.5.4", "version": "22.5.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz",
@ -1031,18 +962,6 @@
"undici-types": "~6.19.2" "undici-types": "~6.19.2"
} }
}, },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"dev": true
},
"node_modules/@ungap/structured-clone": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
"dev": true
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
@ -1173,36 +1092,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -1245,16 +1134,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true "dev": true
}, },
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": { "node_modules/commander": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -1316,28 +1195,6 @@
} }
} }
}, },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"dev": true,
"dependencies": {
"dequal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -1600,52 +1457,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hast-util-to-html": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.2.tgz",
"integrity": "sha512-RP5wNpj5nm1Z8cloDv4Sl4RS8jH5HYa0v93YB6Wb4poEzgMo/dAAL0KcT4974dCjcNG5pkLqTImeFHHCwwfY3g==",
"dev": true,
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-whitespace": "^3.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"property-information": "^6.0.0",
"space-separated-tokens": "^2.0.0",
"stringify-entities": "^4.0.0",
"zwitch": "^2.0.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
"dev": true,
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.2.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.2.tgz",
@ -1825,27 +1636,6 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true "dev": true
}, },
"node_modules/mdast-util-to-hast": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
"dev": true,
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@ungap/structured-clone": "^1.0.0",
"devlop": "^1.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"trim-lines": "^3.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/merge-stream": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -1861,95 +1651,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/micromark-util-character": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz",
"integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==",
"dev": true,
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"dependencies": {
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz",
"integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==",
"dev": true,
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
]
},
"node_modules/micromark-util-sanitize-uri": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz",
"integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==",
"dev": true,
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-encode": "^2.0.0",
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-symbol": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz",
"integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==",
"dev": true,
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
]
},
"node_modules/micromark-util-types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz",
"integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==",
"dev": true,
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
]
},
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -2085,18 +1786,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/oniguruma-to-js": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/oniguruma-to-js/-/oniguruma-to-js-0.4.3.tgz",
"integrity": "sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==",
"dev": true,
"dependencies": {
"regex": "^4.3.2"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
@ -2344,16 +2033,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/property-information": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
"integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -2404,12 +2083,6 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/regex": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/regex/-/regex-4.3.2.tgz",
"integrity": "sha512-kK/AA3A9K6q2js89+VMymcboLOlF5lZRCYJv3gzszXFHBr6kO6qLGzbm+UIugBEV8SMMKCTR59txoY6ctRHYVw==",
"dev": true
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.8", "version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@ -2525,20 +2198,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/shiki": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-1.17.6.tgz",
"integrity": "sha512-RejGugKpDM75vh6YtF9R771acxHRDikC/01kxsUGW+Pnaz3pTY+c8aZB5CnD7p0vuFPs1HaoAIU/4E+NCfS+mQ==",
"dev": true,
"dependencies": {
"@shikijs/core": "1.17.6",
"@shikijs/engine-javascript": "1.17.6",
"@shikijs/engine-oniguruma": "1.17.6",
"@shikijs/types": "1.17.6",
"@shikijs/vscode-textmate": "^9.2.2",
"@types/hast": "^3.0.4"
}
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@ -2575,16 +2234,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -2644,20 +2293,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"dev": true,
"dependencies": {
"character-entities-html4": "^2.0.0",
"character-entities-legacy": "^3.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/strip-ansi": { "node_modules/strip-ansi": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
@ -2894,16 +2529,6 @@
"tree-kill": "cli.js" "tree-kill": "cli.js"
} }
}, },
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ts-interface-checker": { "node_modules/ts-interface-checker": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@ -2980,108 +2605,12 @@
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true "dev": true
}, },
"node_modules/unist-util-is": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
"integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==",
"dev": true,
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
"dev": true,
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
"dev": true,
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
"integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
"dev": true,
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit-parents": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz",
"integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==",
"dev": true,
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true "dev": true
}, },
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
"dev": true,
"dependencies": {
"@types/unist": "^3.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz",
"integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==",
"dev": true,
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-stringify-position": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
@ -3216,16 +2745,6 @@
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
} }
} }
} }

View file

@ -1,10 +1,10 @@
{ {
"name": "htmgo-js", "name": "mhtml-js",
"version": "1.0.0", "version": "1.0.0",
"main": "htmgo.js", "main": "mhtml.js",
"scripts": { "scripts": {
"watch": "tsup --watch --config ./tsup.config.ts --sourcemap inline", "watch": "tsup ./mhtml.ts --watch --config ./tsup.config.ts --sourcemap inline",
"build": "tsup --minify --config ./tsup.config.ts", "build": "tsup ./mhtml.ts --minify --config ./tsup.config.ts",
"tailwind:watch": "npx tailwindcss -i ./input.css -o ./output.css --watch", "tailwind:watch": "npx tailwindcss -i ./input.css -o ./output.css --watch",
"pretty": "prettier --write ." "pretty": "prettier --write ."
}, },
@ -18,7 +18,6 @@
"@swc/core": "^1.7.26", "@swc/core": "^1.7.26",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"shiki": "^1.17.6",
"tailwindcss": "^3.4.11", "tailwindcss": "^3.4.11",
"tsup": "^8.2.4", "tsup": "^8.2.4",
"typescript": "^5.6.2" "typescript": "^5.6.2"

View file

@ -2,23 +2,23 @@ import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
format: ["esm"], format: ["esm"],
entry: ["htmgo.ts"], entry: ["./src/mhtml.ts"],
outDir: "./../dist", outDir: "./../dist",
dts: false, dts: false,
shims: true, shims: true,
skipNodeModulesBundle: true, skipNodeModulesBundle: true,
clean: false, clean: false,
target: "esnext", target: "esnext",
treeshake: true, treeshake: false,
platform: "browser", platform: "browser",
outExtension: () => { outExtension: () => {
return { return {
js: ".js", js: ".js",
}; };
}, },
minify: true, minify: false,
bundle: true, bundle: true,
splitting: true,
// https://github.com/egoist/tsup/issues/619 // https://github.com/egoist/tsup/issues/619
noExternal: [/(.*)/], noExternal: [/(.*)/],
splitting: false,
}); });

View file

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

View file

@ -1,32 +0,0 @@
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/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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20250703190716-06f01b3d7c1b h1:jvfp35fig2TzBjAgw82fe8+7cvaLX9EbipZUlj8FDDY=
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20250703190716-06f01b3d7c1b/go.mod h1:FraJsj3NRuLBQDk83ZVa+psbNRNLe+rajVtVhYMEme4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,34 +0,0 @@
package internal
import (
"sync"
"time"
)
// Debouncer is a struct that holds the debounce logic
type Debouncer struct {
delay time.Duration
timer *time.Timer
mu sync.Mutex
}
// NewDebouncer creates a new Debouncer with the specified delay
func NewDebouncer(delay time.Duration) *Debouncer {
return &Debouncer{
delay: delay,
}
}
// Do calls the provided function after the delay, resetting the delay if called again
func (d *Debouncer) Do(f func()) {
d.mu.Lock()
defer d.mu.Unlock()
// If there's an existing timer, stop it
if d.timer != nil {
d.timer.Stop()
}
// Create a new timer
d.timer = time.AfterFunc(d.delay, f)
}

View file

@ -1,111 +0,0 @@
package dirutil
import (
"errors"
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/framework/config"
"io"
"log/slog"
"os"
"path/filepath"
)
func HasFileFromRoot(file string) bool {
cwd := process.GetWorkingDir()
path := filepath.Join(cwd, file)
_, err := os.Stat(path)
return err == nil
}
func GetConfig() *config.ProjectConfig {
return config.FromConfigFile(process.GetWorkingDir())
}
func CreateHtmgoDir() {
if !HasFileFromRoot("__htmgo") {
CreateDirFromRoot("__htmgo")
}
}
func CreateDirFromRoot(dir string) error {
cwd := process.GetWorkingDir()
path := filepath.Join(cwd, dir)
return os.MkdirAll(path, 0700)
}
func CopyDir(srcDir, dstDir string, predicate func(path string, exists bool) bool) error {
// Walk the source directory tree.
return filepath.Walk(srcDir, func(srcPath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Construct the corresponding destination path.
relPath, err := filepath.Rel(srcDir, srcPath)
if err != nil {
return err
}
dstPath := filepath.Join(dstDir, relPath)
if info.IsDir() {
// If it's a directory, create the corresponding directory in the destination.
err := os.MkdirAll(dstPath, 0700)
if err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}
} else {
exists := true
if _, err := os.Stat(dstPath); errors.Is(err, os.ErrNotExist) {
exists = false
}
if predicate(srcPath, exists) {
err := CopyFile(srcPath, dstPath)
if err != nil {
return err
}
}
// If it's a file, copy the file.
}
return nil
})
}
func MoveFile(src, dst string) error {
slog.Debug("moving file", slog.String("src", src), slog.String("dst", dst))
// Copy the file.
err := CopyFile(src, dst)
if err != nil {
return fmt.Errorf("failed to copy file: %v", err)
}
// Remove the source file.
err = os.Remove(src)
if err != nil {
return fmt.Errorf("failed to remove source file: %v", err)
}
return nil
}
func CopyFile(src, dst string) error {
slog.Debug("copying file", slog.String("src", src), slog.String("dst", dst))
// Open the source file for reading.
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %v", err)
}
defer srcFile.Close()
// Create the destination file.
dstFile, err := os.Create(dst)
if err != nil {
return fmt.Errorf("failed to create destination file: %v", err)
}
defer dstFile.Close()
// Copy the content from srcFile to dstFile.
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("failed to copy file contents: %v", err)
}
return nil
}
func DeleteDir(dir string) error {
return os.RemoveAll(dir)
}

View file

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

View file

@ -1,25 +0,0 @@
package internal
import (
"log/slog"
"os"
"strings"
)
func GetLogLevel() slog.Level {
// Get the log level from the environment variable
logLevel := os.Getenv("LOG_LEVEL")
switch strings.ToUpper(logLevel) {
case "DEBUG":
return slog.LevelDebug
case "INFO":
return slog.LevelInfo
case "WARN":
return slog.LevelWarn
case "ERROR":
return slog.LevelError
default:
// Default to INFO if no valid log level is set
return slog.LevelInfo
}
}

View file

@ -1,150 +0,0 @@
package main
import (
"bufio"
"flag"
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal"
"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/downloadtemplate"
"github.com/maddalax/htmgo/cli/htmgo/tasks/formatter"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/cli/htmgo/tasks/reloader"
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
"log/slog"
"os"
"strings"
)
const version = "1.0.6"
func main() {
needsSignals := true
commandMap := make(map[string]*flag.FlagSet)
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate", "format", "version"}
for _, command := range commands {
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
}
if len(os.Args) < 2 {
fmt.Println(fmt.Sprintf("Usage: htmgo [%s]", strings.Join(commands, " | ")))
os.Exit(1)
}
c := commandMap[os.Args[1]]
if c == nil {
fmt.Println(fmt.Sprintf("Usage: htmgo [%s]", strings.Join(commands, " | ")))
os.Exit(1)
return
}
err := c.Parse(os.Args[2:])
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
return
}
slog.SetLogLoggerLevel(internal.GetLogLevel())
taskName := os.Args[1]
slog.Debug("Running task:", slog.String("task", taskName))
slog.Debug("working dir:", slog.String("dir", process.GetWorkingDir()))
if taskName == "format" {
needsSignals = false
}
done := make(chan bool, 1)
if needsSignals {
done = RegisterSignals()
}
if taskName == "watch" {
fmt.Printf("Running in watch mode\n")
os.Setenv("ENV", "development")
os.Setenv("WATCH_MODE", "true")
fmt.Printf("Starting processes...\n")
copyassets.CopyAssets()
fmt.Printf("Generating CSS...\n")
css.GenerateCss(process.ExitOnError)
// generate ast needs to be run after css generation
astgen.GenAst(process.ExitOnError)
run.EntGenerate()
fmt.Printf("Starting server...\n")
process.KillAll()
go func() {
_ = run.Server()
}()
startWatcher(reloader.OnFileChange)
} else {
if taskName == "version" {
fmt.Printf("htmgo cli version %s\n", version)
os.Exit(0)
}
if taskName == "format" {
if len(os.Args) < 3 {
fmt.Println(fmt.Sprintf("Usage: htmgo format <file>"))
os.Exit(1)
}
file := os.Args[2]
if file == "." {
formatter.FormatDir(process.GetWorkingDir())
} else {
formatter.FormatFile(os.Args[2])
}
} else if taskName == "schema" {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter entity name:")
text, _ := reader.ReadString('\n')
text = strings.TrimSuffix(text, "\n")
run.EntNewSchema(text)
} else if taskName == "generate" {
run.EntGenerate()
astgen.GenAst(process.ExitOnError)
} else if taskName == "setup" {
run.Setup()
} else if taskName == "css" {
_ = css.GenerateCss(process.ExitOnError)
} else if taskName == "ast" {
css.GenerateCss(process.ExitOnError)
_ = astgen.GenAst(process.ExitOnError)
} else if taskName == "run" {
run.MakeBuildable()
_ = run.Server(process.ExitOnError)
} else if taskName == "template" {
name := ""
if len(os.Args) > 2 {
name = os.Args[2]
} else {
reader := bufio.NewReader(os.Stdin)
fmt.Print("What would you like to call your new app?: ")
name, _ = reader.ReadString('\n')
}
name = strings.TrimSuffix(name, "\n")
name = strings.ReplaceAll(name, " ", "-")
name = strings.ToLower(name)
downloadtemplate.DownloadTemplate(fmt.Sprintf("./%s", name))
} else if taskName == "build" {
run.Build()
} else if taskName == "generate" {
astgen.GenAst(process.ExitOnError)
} else {
fmt.Println(fmt.Sprintf("Usage: htmgo [%s]", strings.Join(commands, " | ")))
}
os.Exit(0)
}
<-done
}

View file

@ -1,33 +0,0 @@
package main
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"os"
"os/signal"
"syscall"
)
func RegisterSignals() chan bool {
// Create a channel to receive OS signals
sigs := make(chan os.Signal, 1)
// Register the channel to receive interrupt and terminate signals
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
done := make(chan bool, 1)
// Run a goroutine to handle signals
go func() {
// Block until a signal is received
sig := <-sigs
fmt.Println()
fmt.Println("Received signal:", sig)
// Perform cleanup
fmt.Println("Cleaning up...")
process.OnShutdown()
// Signal that cleanup is done
done <- true
}()
return done
}

View file

@ -1,555 +0,0 @@
package astgen
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"unicode"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/framework/h"
"golang.org/x/mod/modfile"
)
type Page struct {
Path string
FuncName string
Package string
Import string
}
type Partial struct {
FuncName string
Package string
Import string
Path string
}
const GeneratedDirName = "__htmgo"
const HttpModuleName = "net/http"
const ChiModuleName = "github.com/go-chi/chi/v5"
const ModuleName = "github.com/maddalax/htmgo/framework/h"
var PackageName = fmt.Sprintf("package %s", GeneratedDirName)
var GeneratedFileLine = fmt.Sprintf("// Package %s THIS FILE IS GENERATED. DO NOT EDIT.", GeneratedDirName)
func toPascaleCase(input string) string {
words := strings.Split(input, "_")
for i := range words {
words[i] = strings.Title(strings.ToLower(words[i]))
}
return strings.Join(words, "")
}
func isValidGoVariableName(name string) bool {
// Variable name must not be empty
if name == "" {
return false
}
// First character must be a letter or underscore
if !unicode.IsLetter(rune(name[0])) && name[0] != '_' {
return false
}
// Remaining characters must be letters, digits, or underscores
for _, char := range name[1:] {
if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' {
return false
}
}
return true
}
func normalizePath(path string) string {
return strings.ReplaceAll(path, `\`, "/")
}
func sliceCommonPrefix(dir1, dir2 string) string {
// Use filepath.Clean to normalize the paths
dir1 = filepath.Clean(dir1)
dir2 = filepath.Clean(dir2)
// Find the common prefix
commonPrefix := dir1
if len(dir1) > len(dir2) {
commonPrefix = dir2
}
for !strings.HasPrefix(dir1, commonPrefix) {
commonPrefix = filepath.Dir(commonPrefix)
}
// Slice off the common prefix
slicedDir1 := strings.TrimPrefix(dir1, commonPrefix)
slicedDir2 := strings.TrimPrefix(dir2, commonPrefix)
// Remove leading slashes
slicedDir1 = strings.TrimPrefix(slicedDir1, string(filepath.Separator))
slicedDir2 = strings.TrimPrefix(slicedDir2, string(filepath.Separator))
// Return the longer one
if len(slicedDir1) > len(slicedDir2) {
return normalizePath(slicedDir1)
}
return normalizePath(slicedDir2)
}
func hasOnlyReqContextParam(funcType *ast.FuncType) bool {
if len(funcType.Params.List) != 1 {
return false
}
if funcType.Params.List[0].Names == nil {
return false
}
if len(funcType.Params.List[0].Names) != 1 {
return false
}
t := funcType.Params.List[0].Type
name, ok := t.(*ast.StarExpr)
if !ok {
return false
}
selectorExpr, ok := name.X.(*ast.SelectorExpr)
if !ok {
return false
}
ident, ok := selectorExpr.X.(*ast.Ident)
if !ok {
return false
}
return ident.Name == "h" && selectorExpr.Sel.Name == "RequestContext"
}
func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial) bool) ([]Partial, error) {
var partials []Partial
cwd := process.GetWorkingDir()
// Walk through the directory to find all Go files.
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Only process Go files.
if !strings.HasSuffix(path, ".go") {
return nil
}
// Parse the Go file.
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, path, nil, parser.AllErrors)
if err != nil {
return err
}
// Inspect the AST for function declarations.
ast.Inspect(node, func(n ast.Node) bool {
// Check if the node is a function declaration.
if funcDecl, ok := n.(*ast.FuncDecl); ok {
// Only consider exported (public) partials.
if funcDecl.Name.IsExported() {
// Check the return type.
if funcDecl.Type.Results != nil {
for _, result := range funcDecl.Type.Results.List {
// Check if the return type is *h.Partial.
if starExpr, ok := result.Type.(*ast.StarExpr); ok {
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
// Check if the package name is 'h' and type is 'Partial'.
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
if selectorExpr.Sel.Name == "Partial" && hasOnlyReqContextParam(funcDecl.Type) {
p := Partial{
Package: node.Name.Name,
Path: normalizePath(sliceCommonPrefix(cwd, path)),
Import: sliceCommonPrefix(cwd, normalizePath(filepath.Dir(path))),
FuncName: funcDecl.Name.Name,
}
if predicate(p) {
partials = append(partials, p)
}
break
}
}
}
}
}
}
}
}
return true
})
return nil
})
if err != nil {
return nil, err
}
return partials, nil
}
func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
var pages = make([]Page, 0)
// Walk through the directory to find all Go files.
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Only process Go files.
if !strings.HasSuffix(path, ".go") {
return nil
}
// Parse the Go file.
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, path, nil, parser.AllErrors)
if err != nil {
return err
}
// Inspect the AST for function declarations.
ast.Inspect(node, func(n ast.Node) bool {
// Check if the node is a function declaration.
if funcDecl, ok := n.(*ast.FuncDecl); ok {
// Only consider exported (public) functions.
if funcDecl.Name.IsExported() {
// Check the return type.
if funcDecl.Type.Results != nil {
for _, result := range funcDecl.Type.Results.List {
// Check if the return type is *h.Partial.
if starExpr, ok := result.Type.(*ast.StarExpr); ok {
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
// Check if the package name is 'h' and type is 'Partial'.
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
if selectorExpr.Sel.Name == "Page" && hasOnlyReqContextParam(funcDecl.Type) {
pages = append(pages, Page{
Package: node.Name.Name,
Import: normalizePath(filepath.Dir(path)),
Path: normalizePath(path),
FuncName: funcDecl.Name.Name,
})
break
}
}
}
}
}
}
}
}
return true
})
return nil
})
if err != nil {
return nil, err
}
return pages, nil
}
func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
moduleName := GetModuleName()
var routerHandlerMethod = func(path string, caller string) string {
return fmt.Sprintf(`
router.Handle("%s", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
partial := %s(cc)
if partial == nil {
w.WriteHeader(404)
return
}
h.PartialView(w, partial)
}))`, path, caller)
}
handlerMethods := make([]string, 0)
for _, f := range partials {
caller := fmt.Sprintf("%s.%s", f.Package, f.FuncName)
path := fmt.Sprintf("/%s/%s.%s", moduleName, f.Import, f.FuncName)
handlerMethods = append(handlerMethods, routerHandlerMethod(path, caller))
}
registerFunction := fmt.Sprintf(`
func RegisterPartials(router *chi.Mux) {
%s
}
`, strings.Join(handlerMethods, "\n"))
builder.AppendLine(registerFunction)
}
func writePartialsFile() {
config := dirutil.GetConfig()
cwd := process.GetWorkingDir()
partialPath := filepath.Join(cwd)
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
return partial.FuncName != "GetPartialFromContext"
})
partials = h.Filter(partials, func(partial Partial) bool {
return !dirutil.IsGlobExclude(partial.Path, config.AutomaticPartialRoutingIgnore)
})
if err != nil {
fmt.Println(err)
return
}
builder := NewCodeBuilder(nil)
builder.AppendLine(GeneratedFileLine)
builder.AppendLine(PackageName)
builder.AddImport(ChiModuleName)
if len(partials) > 0 {
builder.AddImport(ModuleName)
builder.AddImport(HttpModuleName)
}
moduleName := GetModuleName()
for _, partial := range partials {
builder.AddImport(fmt.Sprintf(`%s/%s`, moduleName, partial.Import))
}
buildGetPartialFromContext(builder, partials)
WriteFile(filepath.Join(GeneratedDirName, "partials-generated.go"), func(content *ast.File) string {
return builder.String()
})
}
func formatRoute(path string) string {
path = strings.TrimSuffix(path, "index.go")
path = strings.TrimSuffix(path, ".go")
path = strings.TrimPrefix(path, "pages/")
path = strings.TrimPrefix(path, "pages\\")
path = strings.ReplaceAll(path, "$", ":")
path = strings.ReplaceAll(path, "_", "/")
path = strings.ReplaceAll(path, ".", "/")
path = strings.ReplaceAll(path, "\\", "/")
parts := strings.Split(path, "/")
for i, part := range parts {
if strings.HasPrefix(part, ":") {
parts[i] = fmt.Sprintf("{%s}", part[1:])
}
}
path = strings.Join(parts, "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = filepath.Join("/", path)
}
if strings.HasSuffix(path, "/") {
return strings.ReplaceAll(path[:len(path)-1], `\`, "/")
}
return strings.ReplaceAll(filepath.Clean(path), `\`, "/")
}
func writePagesFile() {
config := dirutil.GetConfig()
builder := NewCodeBuilder(nil)
builder.AppendLine(GeneratedFileLine)
builder.AppendLine(PackageName)
builder.AddImport(HttpModuleName)
builder.AddImport(ChiModuleName)
pages, _ := findPublicFuncsReturningHPage("pages")
pages = h.Filter(pages, func(page Page) bool {
return !dirutil.IsGlobExclude(page.Path, config.AutomaticPageRoutingIgnore)
})
if len(pages) > 0 {
builder.AddImport(ModuleName)
}
for _, page := range pages {
if page.Import != "" {
moduleName := GetModuleName()
builder.AddImport(
fmt.Sprintf(`%s/%s`, moduleName, page.Import),
)
}
}
fName := "RegisterPages"
body := `
`
for _, page := range pages {
call := fmt.Sprintf("%s.%s", page.Package, page.FuncName)
body += fmt.Sprintf(
`
router.Get("%s", func(writer http.ResponseWriter, request *http.Request) {
cc := request.Context().Value(h.RequestContextKey).(*h.RequestContext)
h.HtmlView(writer, %s(cc))
})
`, formatRoute(page.Path), call,
)
}
f := Function{
Name: fName,
Parameters: []NameType{
{Name: "router", Type: "*chi.Mux"},
},
Body: body,
}
builder.Append(builder.BuildFunction(f))
WriteFile(filepath.Join(GeneratedDirName, "pages-generated.go"), func(content *ast.File) string {
return builder.String()
})
}
func writeAssetsFile() {
cwd := process.GetWorkingDir()
config := dirutil.GetConfig()
slog.Debug("writing assets file", slog.String("cwd", cwd), slog.String("config", config.PublicAssetPath))
distAssets := filepath.Join(cwd, "assets", "dist")
hasAssets := false
builder := strings.Builder{}
builder.WriteString(`package assets`)
builder.WriteString("\n")
filepath.WalkDir(distAssets, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasPrefix(d.Name(), ".") {
return nil
}
path = strings.ReplaceAll(path, distAssets, "")
httpUrl := normalizePath(fmt.Sprintf("%s%s", config.PublicAssetPath, path))
path = normalizePath(path)
path = strings.ReplaceAll(path, "/", "_")
path = strings.ReplaceAll(path, "//", "_")
name := strings.ReplaceAll(path, ".", "_")
name = strings.ReplaceAll(name, "-", "_")
name = toPascaleCase(name)
if isValidGoVariableName(name) {
builder.WriteString(fmt.Sprintf(`const %s = "%s"`, name, httpUrl))
builder.WriteString("\n")
hasAssets = true
}
return nil
})
builder.WriteString("\n")
str := builder.String()
if hasAssets {
WriteFile(filepath.Join(GeneratedDirName, "assets", "assets-generated.go"), func(content *ast.File) string {
return str
})
}
}
func HasModuleFile(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
func CheckPagesDirectory(path string) error {
pagesPath := filepath.Join(path, "pages")
_, err := os.Stat(pagesPath)
if err != nil {
return fmt.Errorf("The directory pages does not exist.")
}
return nil
}
func GetModuleName() string {
wd := process.GetWorkingDir()
modPath := filepath.Join(wd, "go.mod")
if HasModuleFile(modPath) == false {
fmt.Fprintf(os.Stderr, "Module not found: go.mod file does not exist.")
return ""
}
checkDir := CheckPagesDirectory(wd)
if checkDir != nil {
fmt.Fprintf(os.Stderr, checkDir.Error())
return ""
}
goModBytes, err := os.ReadFile(modPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading go.mod: %v\n", err)
return ""
}
modName := modfile.ModulePath(goModBytes)
return modName
}
func GenAst(flags ...process.RunFlag) error {
if GetModuleName() == "" {
if slices.Contains(flags, process.ExitOnError) {
os.Exit(1)
}
return fmt.Errorf("error getting module name")
}
writePartialsFile()
writePagesFile()
writeAssetsFile()
WriteFile("__htmgo/setup-generated.go", func(content *ast.File) string {
return fmt.Sprintf(`
// Package __htmgo THIS FILE IS GENERATED. DO NOT EDIT.
package __htmgo
import (
"%s"
)
func Register(r *chi.Mux) {
RegisterPartials(r)
RegisterPages(r)
}
`, ChiModuleName)
})
return nil
}

View file

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

View file

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

View file

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

View file

@ -1,11 +0,0 @@
module astgen-project-sample
go 1.23.0
require github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
require (
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,18 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,21 +0,0 @@
# htmgo configuration
# if tailwindcss is enabled, htmgo will automatically compile your tailwind and output it to assets/dist
tailwind: true
# which directories to ignore when watching for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
watch_ignore: [".git", "node_modules", "dist/*"]
# files to watch for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
watch_files: ["**/*.go", "**/*.css", "**/*.md"]
# files or directories to ignore when automatically registering routes for pages
# supports glob patterns through https://github.com/bmatcuk/doublestar
automatic_page_routing_ignore: ["root.go"]
# files or directories to ignore when automatically registering routes for partials
# supports glob patterns through https://github.com/bmatcuk/doublestar
automatic_partial_routing_ignore: []
# url path of where the public assets are located
public_asset_path: "/public"

View file

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

View file

@ -1,36 +0,0 @@
package main
import (
"astgen-project-sample/__htmgo"
"fmt"
"github.com/maddalax/htmgo/framework/config"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
)
func main() {
locator := service.NewLocator()
cfg := config.Get()
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
// change this in htmgo.yml (public_asset_path)
app.Router.Handle(fmt.Sprintf("%s/*", cfg.PublicAssetPath),
http.StripPrefix(cfg.PublicAssetPath, http.FileServerFS(sub)))
__htmgo.Register(app.Router)
},
})
}

View file

@ -1,30 +0,0 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func IndexPage(ctx *h.RequestContext) *h.Page {
return RootPage(
h.Div(
h.Class("flex flex-col gap-4 items-center pt-24 min-h-screen bg-neutral-100"),
h.H3(
h.Id("intro-text"),
h.Text("hello htmgo"),
h.Class("text-5xl"),
),
h.Div(
h.Class("mt-3"),
),
h.Div(),
),
)
}
func TestPartial(ctx *h.RequestContext) *h.Partial {
return h.NewPartial(
h.Div(
h.Text("Hello World"),
),
)
}

View file

@ -1,40 +0,0 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func RootPage(children ...h.Ren) *h.Page {
title := "htmgo template"
description := "an example of the htmgo template"
author := "htmgo"
url := "https://htmgo.dev"
return h.NewPage(
h.Html(
h.HxExtensions(
h.BaseExtensions(),
),
h.Head(
h.Title(
h.Text(title),
),
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Meta("title", title),
h.Meta("charset", "utf-8"),
h.Meta("author", author),
h.Meta("description", description),
h.Meta("og:title", title),
h.Meta("og:url", url),
h.Link("canonical", url),
h.Meta("og:description", description),
),
h.Body(
h.Div(
h.Class("flex flex-col gap-2 bg-white h-full"),
h.Fragment(children...),
),
),
),
)
}

View file

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

View file

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

View file

@ -1,9 +0,0 @@
package astgen
import (
"fmt"
)
func PanicF(format string, args ...interface{}) {
panic(fmt.Sprintf(format, args...))
}

View file

@ -1,108 +0,0 @@
package copyassets
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/module"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"golang.org/x/mod/modfile"
"log"
"os"
"path/filepath"
"strings"
)
func getModuleVersion(modulePath string) (string, error) {
// Read the go.mod file
data, err := os.ReadFile(process.GetPathRelativeToCwd("go.mod"))
if err != nil {
return "", fmt.Errorf("error reading go.mod: %v", err)
}
// Parse the go.mod file
modFile, err := modfile.Parse(process.GetPathRelativeToCwd("go.mod"), data, nil)
if err != nil {
return "", fmt.Errorf("error parsing go.mod: %v", err)
}
// Find the module version
for _, req := range modFile.Require {
if req.Mod.Path == modulePath {
return req.Mod.Version, nil
}
}
return "", fmt.Errorf("module %s not found in go.mod", modulePath)
}
func CopyAssets() {
dirutil.CreateHtmgoDir()
moduleName := "github.com/maddalax/htmgo/framework"
modulePath := module.GetDependencyPath(moduleName)
assetDir := ""
// Is hosted version and not local version from .work file
if strings.HasPrefix(modulePath, "github.com/") {
version, err := getModuleVersion(modulePath)
if err != nil {
log.Fatalf("Error: %v", err)
}
dirname, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
assetDir = fmt.Sprintf("%s/go/pkg/mod/%s@%s/assets", dirname, modulePath, version)
} else {
assetDir = filepath.Join(modulePath, "assets")
}
assetDistDir := filepath.Join(assetDir, "dist")
assetCssDir := filepath.Join(assetDir, "css")
cwd := process.GetWorkingDir()
destDir := filepath.Join(cwd, "assets")
destDirDist := filepath.Join(destDir, "dist")
destDirCss := filepath.Join(destDir, "css")
err := dirutil.CopyDir(assetDistDir, destDirDist, func(path string, exists bool) bool {
return true
})
if err != nil {
log.Fatalf("Error: %v", err)
}
err = dirutil.CopyDir(assetCssDir, destDirCss, func(path string, exists bool) bool {
if strings.HasSuffix(path, "tailwind.config.js") {
return false
}
return !exists
})
if err != nil {
log.Fatalf("Error: %v", err)
}
if dirutil.HasFileFromRoot("assets/public") {
err = dirutil.CopyDir(filepath.Join(process.GetWorkingDir(), "assets/public"), filepath.Join(process.GetWorkingDir(), "assets/dist"),
func(path string, exists bool) bool {
return true
})
}
if dirutil.GetConfig().Tailwind && !dirutil.HasFileFromRoot("tailwind.config.js") {
err = dirutil.CopyFile(
filepath.Join(assetCssDir, "tailwind.config.js"),
filepath.Join(process.GetWorkingDir(), "tailwind.config.js"),
)
}
if err != nil {
log.Fatalf("Error: %v", err)
}
cmd := fmt.Sprintf("cd %s && git add .", destDirCss)
process.Run(process.NewRawCommand(cmd, cmd, process.Silent))
}

View file

@ -1,108 +0,0 @@
package css
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"log"
"log/slog"
"path/filepath"
"runtime"
)
func IsTailwindEnabled() bool {
return dirutil.GetConfig().Tailwind && dirutil.HasFileFromRoot("tailwind.config.js")
}
func Setup() bool {
if !IsTailwindEnabled() {
slog.Debug("Tailwind is not enabled. Skipping CSS generation.")
return false
}
downloadTailwindCli()
if !dirutil.HasFileFromRoot("assets/css/input.css") {
copyassets.CopyAssets()
}
return true
}
func GetTailwindExecutableName() string {
if runtime.GOOS == "windows" {
return "./__htmgo/tailwind.exe"
} else {
return "./__htmgo/tailwind"
}
}
func GenerateCss(flags ...process.RunFlag) error {
if !Setup() {
return nil
}
exec := GetTailwindExecutableName()
cmd := fmt.Sprintf("%s -i ./assets/css/input.css -o ./assets/dist/main.css -c ./tailwind.config.js", exec)
return process.Run(process.NewRawCommand("tailwind", cmd, append(flags, process.Silent)...))
}
func downloadTailwindCli() {
if dirutil.HasFileFromRoot(GetTailwindExecutableName()) {
slog.Debug("Tailwind CLI already exists. Skipping download.")
return
}
if !IsTailwindEnabled() {
slog.Debug("Tailwind is not enabled. Skipping tailwind cli download.")
return
}
distro := ""
os := runtime.GOOS
arch := runtime.GOARCH
switch {
case os == "darwin" && arch == "arm64":
distro = "macos-arm64"
case os == "darwin" && arch == "amd64":
distro = "macos-x64"
case os == "linux" && arch == "arm64":
distro = "linux-arm64"
case os == "linux" && arch == "amd64":
distro = "linux-x64"
case os == "windows" && arch == "amd64":
distro = "windows-x64.exe"
case os == "windows" && arch == "arm64":
distro = "windows-arm64.exe"
default:
log.Fatal(fmt.Sprintf("Unsupported OS/ARCH: %s/%s", os, arch))
}
fileName := fmt.Sprintf(`tailwindcss-%s`, distro)
url := fmt.Sprintf(`https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.16/%s`, fileName)
cmd := fmt.Sprintf(`curl -LO %s`, url)
process.Run(process.NewRawCommand("tailwind-cli-download", cmd, process.ExitOnError))
outputFileName := GetTailwindExecutableName()
newPath := filepath.Join(process.GetWorkingDir(), outputFileName)
err := dirutil.MoveFile(
filepath.Join(process.GetWorkingDir(), fileName),
newPath)
if err != nil {
log.Fatalf("Error moving file: %s\n", err.Error())
}
if os != "windows" {
err = process.Run(process.NewRawCommand("chmod-tailwind-cli",
fmt.Sprintf(`chmod +x %s`, newPath),
process.ExitOnError))
}
if err != nil {
log.Fatalf("Error moving file: %s\n", err.Error())
}
slog.Debug("Successfully downloaded Tailwind CLI", slog.String("url", url))
}

View file

@ -1,98 +0,0 @@
package downloadtemplate
import (
"flag"
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
"github.com/maddalax/htmgo/cli/htmgo/tasks/util"
"log"
"log/slog"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
func DownloadTemplate(outPath string) {
cwd, _ := os.Getwd()
flag.Parse()
outPath = strings.ReplaceAll(outPath, "\n", "")
outPath = strings.ReplaceAll(outPath, "\r", "")
outPath = strings.ReplaceAll(outPath, " ", "-")
outPath = strings.ToLower(outPath)
if outPath == "" {
fmt.Println("Please provide a name for your app.")
return
}
templateName := "starter-template"
templatePath := filepath.Join("templates", "starter")
re := regexp.MustCompile(`[^a-zA-Z]+`)
// Replace all non-alphabetic characters with an empty string
newModuleName := re.ReplaceAllString(outPath, "")
tempOut := newModuleName + "_temp_" + strconv.FormatInt(time.Now().Unix(), 10)
fmt.Printf("Downloading template %s\n to %s", templateName, tempOut)
err := process.Run(process.NewRawCommand("clone-template", "git clone https://github.com/maddalax/htmgo --depth=1 "+tempOut, process.ExitOnError))
if err != nil {
log.Fatalf("Error cloning the template, error: %s\n", err.Error())
return
}
slog.Debug("provided out path", slog.String("outPath", outPath))
slog.Debug("new module name", slog.String("newModuleName", newModuleName))
slog.Debug("cwd", slog.String("cwd", cwd))
newDir := filepath.Join(cwd, outPath)
slog.Debug("Copying template files to", slog.String("dir", newDir))
dirutil.CopyDir(filepath.Join(tempOut, templatePath), newDir, func(path string, exists bool) bool {
return true
})
dirutil.DeleteDir(tempOut)
process.SetWorkingDir(newDir)
slog.Debug("current working dir", slog.String("cwd", process.GetWorkingDir()))
commands := [][]string{
{"git", "init"},
}
for _, command := range commands {
process.Run(process.NewRawCommand("", strings.Join(command, " "), process.ExitOnError))
}
_ = util.ReplaceTextInFile(filepath.Join(newDir, "go.mod"),
fmt.Sprintf("module %s", templateName),
fmt.Sprintf("module %s", newModuleName))
_ = util.ReplaceTextInDirRecursive(newDir, templateName, newModuleName, func(file string) bool {
return strings.HasSuffix(file, ".go") || strings.HasPrefix(file, "Dockerfile")
})
fmt.Printf("Setting up the project in %s\n", newDir)
process.SetWorkingDir(newDir)
run.Setup()
process.SetWorkingDir("")
fmt.Println("Template downloaded successfully.")
fmt.Println("To start the development server, run the following commands:")
fmt.Printf("cd %s && htmgo watch\n", outPath)
fmt.Printf("To build the project, run the following command:\n")
fmt.Printf("cd %s && htmgo build\n", outPath)
}

View file

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

View file

@ -1,25 +0,0 @@
package module
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"os/exec"
"strings"
)
func GetDependencyPath(dep string) string {
cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", dep)
cmd.Dir = process.GetWorkingDir()
// Run the command and capture the output
output, err := cmd.CombinedOutput() // Use CombinedOutput to capture both stdout and stderr
if err != nil {
fmt.Printf("Command execution failed: %v\n", err)
}
// Convert output to string
dir := strings.TrimSuffix(string(output), "\n")
if strings.Contains(dir, "not a known dependency") {
return dep
}
return dir
}

View file

@ -1,57 +0,0 @@
//go:build linux || darwin
package process
import (
"errors"
"log/slog"
"os"
"os/exec"
"syscall"
"time"
)
func KillProcess(process CmdWithFlags) error {
if process.Cmd == nil || process.Cmd.Process == nil {
return nil
}
slog.Debug("killing process",
slog.String("name", process.Name),
slog.Int("pid", process.Cmd.Process.Pid))
_ = syscall.Kill(-process.Cmd.Process.Pid, syscall.SIGKILL)
time.Sleep(time.Millisecond * 50)
return nil
}
func PrepareCommand(command *exec.Cmd) {
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
func PidExists(pid int32) bool {
if pid <= 0 {
return false
}
proc, err := os.FindProcess(int(pid))
if err != nil {
return false
}
err = proc.Signal(syscall.Signal(0))
if err == nil {
return true
}
if err.Error() == "os: process already finished" {
return false
}
var errno syscall.Errno
ok := errors.As(err, &errno)
if !ok {
return false
}
switch errno {
case syscall.ESRCH:
return false
case syscall.EPERM:
return true
}
return false
}

View file

@ -1,41 +0,0 @@
package process
import (
"fmt"
"os/exec"
"strconv"
"time"
)
import "golang.org/x/sys/windows"
func KillProcess(process CmdWithFlags) error {
if process.Cmd == nil || process.Cmd.Process == nil {
return nil
}
err := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(process.Cmd.Process.Pid)).Run()
if err != nil {
fmt.Println(err)
}
time.Sleep(time.Millisecond * 50)
return nil
}
func PrepareCommand(command *exec.Cmd) {
}
func PidExists(pid int32) bool {
var handle, err = windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
defer windows.CloseHandle(handle)
var exitCode uint32
err = windows.GetExitCodeProcess(handle, &exitCode)
if err != nil {
return false
}
return exitCode == 259
}

View file

@ -1,293 +0,0 @@
package process
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal"
"log/slog"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
type CmdWithFlags struct {
Flags []RunFlag
Name string
Cmd *exec.Cmd
}
type RawCommand struct {
Name string
Args string
Flags []RunFlag
}
func NewRawCommand(name string, args string, flags ...RunFlag) RawCommand {
if name == "" {
name = args
}
c := RawCommand{Name: name, Args: args, Flags: flags}
if c.Flags == nil {
c.Flags = make([]RunFlag, 0)
}
return c
}
var workingDir string
var commands = make(map[string]CmdWithFlags)
func AppendRunning(cmd *exec.Cmd, raw RawCommand) {
slog.Debug("running", slog.String("command", strings.Join(cmd.Args, " ")),
slog.String("dir", cmd.Dir),
slog.String("cwd", GetWorkingDir()))
commands[raw.Name] = CmdWithFlags{Flags: raw.Flags, Name: raw.Name, Cmd: cmd}
}
func GetWorkingDir() string {
if workingDir == "" {
wd, _ := os.Getwd()
return wd
}
return workingDir
}
func SetWorkingDir(dir string) {
workingDir = dir
}
func GetPathRelativeToCwd(path string) string {
return filepath.Join(GetWorkingDir(), path)
}
func shouldSkipKilling(flags []RunFlag, skipFlag []RunFlag) bool {
for _, flag := range flags {
if slices.Contains(skipFlag, flag) {
return true
}
}
return false
}
func StartLogger() {
if internal.GetLogLevel() != slog.LevelDebug {
return
}
go func() {
for {
time.Sleep(time.Second * 5)
items := make([]map[string]string, 0)
for _, cmd := range commands {
data := make(map[string]string)
data["command"] = fmt.Sprintf("%s %s", cmd.Cmd.Path, strings.Join(cmd.Cmd.Args, " "))
if cmd.Cmd.Process != nil {
data["pid"] = fmt.Sprintf("%d", cmd.Cmd.Process.Pid)
}
items = append(items, data)
}
fmt.Printf("Running processes:\n")
for i, item := range items {
fmt.Printf("%d: %+v\n", i, item)
}
fmt.Printf("\n")
}
}()
}
func GetProcessByName(name string) *CmdWithFlags {
for _, cmd := range commands {
if cmd.Name == name {
return &cmd
}
}
return nil
}
func OnShutdown() {
// request for shutdown
for _, cmd := range commands {
if cmd.Cmd != nil && cmd.Cmd.Process != nil {
cmd.Cmd.Process.Signal(os.Interrupt)
}
}
// give it a second
time.Sleep(time.Second * 1)
// force kill
KillAll()
}
func KillAll(skipFlag ...RunFlag) {
tries := 0
updatedCommands := make(map[string]CmdWithFlags)
for {
tries++
allFinished := true
for _, cmd := range commands {
if cmd.Cmd.Process == nil {
allFinished = false
if tries > 50 {
args := strings.Join(cmd.Cmd.Args, " ")
slog.Debug("process is not running after 50 tries, breaking.", slog.String("command", args))
allFinished = true
break
} else {
time.Sleep(time.Millisecond * 50)
continue
}
} else {
updatedCommands[cmd.Name] = cmd
}
}
if allFinished {
break
}
}
commands = make(map[string]CmdWithFlags)
for _, command := range updatedCommands {
if command.Cmd != nil && command.Cmd.Process != nil {
commands[command.Name] = command
}
}
for _, command := range commands {
if shouldSkipKilling(command.Flags, skipFlag) {
continue
}
err := KillProcess(command)
if err != nil {
continue
}
}
for {
finished := true
for _, c := range commands {
if c.Cmd.Process == nil {
continue
}
if shouldSkipKilling(c.Flags, skipFlag) {
continue
}
exists := PidExists(int32(c.Cmd.Process.Pid))
if exists {
KillProcess(c)
finished = false
}
}
if finished {
break
} else {
slog.Debug("waiting for all processes to exit\n")
time.Sleep(time.Millisecond * 5)
}
}
commands = make(map[string]CmdWithFlags)
slog.Debug("all processes killed\n")
}
func RunOrExit(command RawCommand) {
command.Flags = append(command.Flags, ExitOnError)
_ = Run(command)
}
type RunFlag int
const (
ExitOnError RunFlag = iota
Silent
KillOnlyOnExit
)
func RunMany(commands ...RawCommand) error {
for _, command := range commands {
err := Run(command)
if err != nil {
if slices.Contains(command.Flags, ExitOnError) {
os.Exit(1)
}
return err
}
}
return nil
}
var mutex = &sync.Mutex{}
func Run(command RawCommand) error {
mutex.Lock()
parts := strings.Fields(command.Args)
args := make([]string, 0)
if len(parts) > 1 {
args = parts[1:]
}
path := parts[0]
existing := GetProcessByName(command.Name)
if existing != nil {
slog.Debug("process already running, killing it", slog.String("command", command.Name))
KillProcess(*existing)
time.Sleep(time.Millisecond * 50)
} else {
slog.Debug("no existing process found for %s, safe to run...", slog.String("command", command.Name))
}
cmd := exec.Command(path, args...)
PrepareCommand(cmd)
if slices.Contains(command.Flags, Silent) {
cmd.Stdout = nil
cmd.Stderr = nil
} else {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
if workingDir != "" {
cmd.Dir = workingDir
}
AppendRunning(cmd, command)
mutex.Unlock()
err := cmd.Run()
slog.Debug("command finished",
slog.String("command", command.Name),
slog.String("args", command.Args),
slog.String("dir", cmd.Dir),
slog.String("cwd", GetWorkingDir()),
slog.String("error", fmt.Sprintf("%v", err)))
delete(commands, command.Name)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "signal: killed") {
return nil
}
if slices.Contains(command.Flags, ExitOnError) {
slog.Error("Error running command: ",
slog.String("error", err.Error()),
slog.String("command", command.Name))
os.Exit(1)
}
return err
}

View file

@ -1,175 +0,0 @@
package reloader
import (
"fmt"
"github.com/fsnotify/fsnotify"
"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/run"
"github.com/maddalax/htmgo/cli/htmgo/tasks/util"
"log/slog"
"strings"
"sync"
"time"
)
type Change struct {
name string
op fsnotify.Op
}
func NewChange(event *fsnotify.Event) *Change {
return &Change{name: event.Name, op: event.Op}
}
func (c *Change) Name() string {
return c.name
}
func (c *Change) HasAnyPrefix(prefix ...string) bool {
for _, s := range prefix {
if strings.HasPrefix(c.name, s) {
return true
}
}
return false
}
func (c *Change) HasAnySuffix(suffix ...string) bool {
for _, s := range suffix {
if strings.HasSuffix(c.name, s) {
return true
}
}
return false
}
func (c *Change) IsGenerated() bool {
return c.HasAnySuffix("generated.go")
}
func (c *Change) IsWrite() bool {
return c.op == fsnotify.Write
}
func (c *Change) IsGo() bool {
return c.HasAnySuffix(".go")
}
type Tasks struct {
AstGen bool
Run bool
Ent bool
Css bool
}
func OnFileChange(version string, events []*fsnotify.Event) {
now := time.Now()
tasks := Tasks{}
hasTask := false
for _, event := range events {
c := NewChange(event)
if c.HasAnySuffix(".go~", ".css~") {
continue
}
if c.IsGenerated() {
continue
}
if c.IsGo() && c.HasAnyPrefix("pages/", "partials/") {
tasks.AstGen = true
hasTask = true
}
if c.IsGo() {
tasks.Run = true
tasks.Css = true
hasTask = true
}
if c.HasAnySuffix(".md") {
tasks.Run = true
hasTask = true
}
if c.HasAnyPrefix("ent/schema") {
tasks.Ent = true
hasTask = true
}
// framework assets changed
if c.HasAnySuffix("assets/dist/htmgo.js") {
copyassets.CopyAssets()
//tasks.Run = true
}
// something in public folder changed
if c.HasAnyPrefix("assets/public/") {
copyassets.CopyAssets()
}
if hasTask {
slog.Info("file changed", slog.String("version", version), slog.String("file", c.Name()))
}
}
if !hasTask {
return
}
deps := make([]func() any, 0)
if tasks.AstGen {
go func() {
util.Trace("generate ast", func() any {
astgen.GenAst()
return nil
})
}()
}
if tasks.Css {
deps = append(deps, func() any {
return util.Trace("generate css", func() any {
css.GenerateCss()
return nil
})
})
}
if tasks.Ent {
deps = append(deps, func() any {
return util.Trace("generate ent", func() any {
run.EntGenerate()
return nil
})
})
}
wg := sync.WaitGroup{}
for _, dep := range deps {
wg.Add(1)
go func(dep func() any) {
defer wg.Done()
err := dep()
if err != nil {
fmt.Println(err)
}
}(dep)
}
wg.Wait()
if tasks.Run {
go run.Server()
}
slog.Info("reloaded in", slog.Duration("duration", time.Since(now)))
}

View file

@ -1,35 +0,0 @@
package run
import (
"fmt"
"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"
"os"
)
func MakeBuildable() {
copyassets.CopyAssets()
css.GenerateCss(process.ExitOnError)
astgen.GenAst(process.ExitOnError)
}
func Build() {
MakeBuildable()
_ = os.RemoveAll("./dist")
err := os.Mkdir("./dist", 0755)
if err != nil {
fmt.Println("Error creating dist directory", err)
os.Exit(1)
}
if os.Getenv("SKIP_GO_BUILD") != "1" {
process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -tags prod -o ./dist")))
}
fmt.Printf("Executable built at %s\n", process.GetPathRelativeToCwd("dist"))
}

View file

@ -1,21 +0,0 @@
package run
import (
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"runtime"
)
func EntNewSchema(name string) {
process.RunOrExit(process.NewRawCommand("", "GOWORK=off go run -mod=mod entgo.io/ent/cmd/ent new "+name))
}
func EntGenerate() {
if dirutil.HasFileFromRoot("ent/schema") {
if runtime.GOOS == "windows" {
process.RunOrExit(process.NewRawCommand("ent-generate", "go generate ./ent"))
} else {
process.RunOrExit(process.NewRawCommand("ent-generate", "bash -c GOWORK=off go generate ./ent"))
}
}
}

View file

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

View file

@ -1,12 +0,0 @@
package run
import (
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
)
func Setup() {
process.RunOrExit(process.NewRawCommand("", "go mod download"))
process.RunOrExit(process.NewRawCommand("", "go mod tidy"))
MakeBuildable()
EntGenerate()
}

View file

@ -1,27 +0,0 @@
package util
import (
"io/fs"
"os"
"path/filepath"
"strings"
)
func ReplaceTextInFile(file string, text string, replacement string) error {
bytes, err := os.ReadFile(file)
if err != nil {
return err
}
str := string(bytes)
updated := strings.ReplaceAll(str, text, replacement)
return os.WriteFile(file, []byte(updated), 0644)
}
func ReplaceTextInDirRecursive(dir string, text string, replacement string, filter func(file string) bool) error {
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if filter(filepath.Base(path)) {
_ = ReplaceTextInFile(path, text, replacement)
}
return nil
})
}

View file

@ -1,13 +0,0 @@
package util
import (
"log/slog"
"time"
)
func Trace(name string, cb func() any) any {
now := time.Now()
result := cb()
slog.Debug("trace", slog.String("name", name), slog.Duration("duration", time.Since(now)))
return result
}

View file

@ -1,136 +0,0 @@
package main
import (
"github.com/fsnotify/fsnotify"
"github.com/google/uuid"
"github.com/maddalax/htmgo/cli/htmgo/internal"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/module"
"log"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
)
func startWatcher(cb func(version string, file []*fsnotify.Event)) {
events := make([]*fsnotify.Event, 0)
debouncer := internal.NewDebouncer(500 * time.Millisecond)
config := dirutil.GetConfig()
defer func() {
if r := recover(); r != nil {
slog.Debug("Recovered from fatal error:", slog.String("error", r.(error).Error()))
}
}()
// Create new watcher.
watcher, err := fsnotify.NewWatcher()
if err != nil {
panic(err)
}
defer watcher.Close()
// Start listening for events.
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Has(fsnotify.Remove) {
if dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) {
watcher.Remove(event.Name)
continue
}
}
if event.Has(fsnotify.Create) {
if dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) {
watcher.Add(event.Name)
continue
}
info, err := os.Stat(event.Name)
if err != nil {
slog.Error("Error getting file info:", slog.String("path", event.Name), slog.String("error", err.Error()))
continue
}
if info.IsDir() {
err = watcher.Add(event.Name)
if err != nil {
slog.Error("Error adding directory to watcher:", slog.String("path", event.Name), slog.String("error", err.Error()))
} else {
slog.Debug("Watching directory:", slog.String("path", event.Name))
}
}
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
if !dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) {
continue
}
events = append(events, &event)
debouncer.Do(func() {
seen := make(map[string]bool)
dedupe := make([]*fsnotify.Event, 0)
for _, e := range events {
if _, ok := seen[e.Name]; !ok {
seen[e.Name] = true
dedupe = append(dedupe, e)
}
}
cb(uuid.NewString()[0:6], dedupe)
events = make([]*fsnotify.Event, 0)
})
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
slog.Error("error:", slog.String("error", err.Error()))
}
}
}()
rootDir := "."
frameworkPath := module.GetDependencyPath("github.com/maddalax/htmgo/framework")
if !strings.HasPrefix(frameworkPath, "github.com/") {
assetPath := filepath.Join(frameworkPath, "assets", "dist")
slog.Debug("Watching directory:", slog.String("path", assetPath))
watcher.Add(assetPath)
}
// Walk through the root directory and add all subdirectories to the watcher
err = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Ignore directories in the ignoredDirs list
if dirutil.IsGlobExclude(path, config.WatchIgnore) {
return filepath.SkipDir
}
// Only watch directories
if info.IsDir() {
err = watcher.Add(path)
if err != nil {
slog.Error("Error adding directory to watcher:", slog.String("error", err.Error()))
} else {
slog.Debug("Watching directory:", slog.String("path", path))
}
}
return nil
})
if err != nil {
log.Fatal(err)
}
done := RegisterSignals()
<-done
println("process exited")
}

136
database/database.go Normal file
View file

@ -0,0 +1,136 @@
package database
import (
"context"
"encoding/json"
"github.com/redis/go-redis/v9"
"sync"
"time"
)
var (
once sync.Once
rdb *redis.Client
)
func Connect() *redis.Client {
once.Do(func() {
var ctx = context.Background()
var err error
rdb = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
if err != nil {
panic(err)
}
cmd := rdb.Ping(ctx)
if cmd.Err() != nil {
panic(err)
}
})
return rdb
}
func Incr(key string) int64 {
db := Connect()
result := db.Incr(context.Background(), key)
return result.Val()
}
func Set[T any](key string, value T) error {
db := Connect()
serialized, err := json.Marshal(value)
if err != nil {
return err
}
result := db.Set(context.Background(), key, serialized, time.Duration(0))
return result.Err()
}
func HSet[T any](set string, key string, value T) error {
db := Connect()
serialized, err := json.Marshal(value)
if err != nil {
return err
}
result := db.HSet(context.Background(), set, key, serialized)
return result.Err()
}
func HIncr(set string, key string) int64 {
db := Connect()
result := db.HIncrBy(context.Background(), set, key, 1)
return result.Val()
}
func HGet[T any](set string, key string) *T {
db := Connect()
val, err := db.HGet(context.Background(), set, key).Result()
if err != nil || val == "" {
return nil
}
result := new(T)
err = json.Unmarshal([]byte(val), result)
if err != nil {
return nil
}
return result
}
func GetOrSet[T any](key string, cb func() T) (*T, error) {
db := Connect()
val, err := db.Get(context.Background(), key).Result()
if err == nil {
result := new(T)
err = json.Unmarshal([]byte(val), result)
if err != nil {
return nil, err
}
return result, nil
}
value := cb()
err = Set(key, value)
if err != nil {
return nil, err
}
return &value, nil
}
func Get[T any](key string) (*T, error) {
db := Connect()
val, err := db.Get(context.Background(), key).Result()
if err != nil {
return nil, err
}
result := new(T)
err = json.Unmarshal([]byte(val), result)
if err != nil {
return nil, err
}
return result, nil
}
func HList[T any](key string) ([]*T, error) {
db := Connect()
val, err := db.HGetAll(context.Background(), key).Result()
if err != nil {
return nil, err
}
result := make([]*T, len(val))
count := 0
for _, t := range val {
item := new(T)
err = json.Unmarshal([]byte(t), item)
if err != nil {
return nil, err
}
result[count] = item
count++
}
return result, nil
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,155 +0,0 @@
package chat
import (
"chat/internal/db"
"chat/sse"
"context"
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"time"
)
type Manager struct {
socketManager *sse.SocketManager
queries *db.Queries
service *Service
}
func NewManager(locator *service.Locator) *Manager {
return &Manager{
socketManager: service.Get[sse.SocketManager](locator),
queries: service.Get[db.Queries](locator),
service: NewService(locator),
}
}
func (m *Manager) StartListener() {
c := make(chan sse.SocketEvent, 1)
m.socketManager.Listen(c)
for {
select {
case event := <-c:
switch event.Type {
case sse.ConnectedEvent:
m.OnConnected(event)
case sse.DisconnectedEvent:
m.OnDisconnected(event)
case sse.MessageEvent:
m.onMessage(event)
default:
fmt.Printf("Unknown event type: %s\n", event.Type)
}
}
}
}
func (m *Manager) dispatchConnectedUsers(roomId string, predicate func(conn sse.SocketConnection) bool) {
connectedUsers := make([]db.User, 0)
// backfill all existing clients to the connected client
m.socketManager.ForEachSocket(roomId, func(conn sse.SocketConnection) {
if !predicate(conn) {
return
}
user, err := m.queries.GetUserBySessionId(context.Background(), conn.Id)
if err != nil {
return
}
connectedUsers = append(connectedUsers, user)
})
m.socketManager.ForEachSocket(roomId, func(conn sse.SocketConnection) {
m.socketManager.SendText(conn.Id, h.Render(ConnectedUsers(connectedUsers, conn.Id)))
})
}
func (m *Manager) OnConnected(e sse.SocketEvent) {
room, _ := m.service.GetRoom(e.RoomId)
if room == nil {
m.socketManager.CloseWithMessage(e.Id, "invalid room")
return
}
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
if err != nil {
m.socketManager.CloseWithMessage(e.Id, "invalid user")
return
}
fmt.Printf("User %s connected to %s\n", user.Name, e.RoomId)
m.dispatchConnectedUsers(e.RoomId, func(conn sse.SocketConnection) bool {
return true
})
m.backFill(e.Id, e.RoomId)
}
func (m *Manager) OnDisconnected(e sse.SocketEvent) {
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
if err != nil {
return
}
room, err := m.service.GetRoom(e.RoomId)
if err != nil {
return
}
fmt.Printf("User %s disconnected from %s\n", user.Name, room.ID)
m.dispatchConnectedUsers(e.RoomId, func(conn sse.SocketConnection) bool {
return conn.Id != e.Id
})
}
func (m *Manager) backFill(socketId string, roomId string) {
messages, _ := m.queries.GetLastMessages(context.Background(), db.GetLastMessagesParams{
ChatRoomID: roomId,
Limit: 200,
})
for _, message := range messages {
parsed, _ := time.Parse("2006-01-02 15:04:05", message.CreatedAt)
m.socketManager.SendText(socketId,
h.Render(MessageRow(&Message{
UserId: message.UserID,
UserName: message.UserName,
Message: message.Message,
CreatedAt: parsed,
})),
)
}
}
func (m *Manager) onMessage(e sse.SocketEvent) {
message := e.Payload["message"].(string)
if message == "" {
return
}
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
if err != nil {
fmt.Printf("Error getting user: %v\n", err)
return
}
saved := m.service.InsertMessage(
&user,
e.RoomId,
message,
)
if saved != nil {
m.socketManager.BroadcastText(
e.RoomId,
h.Render(MessageRow(saved)),
func(conn sse.SocketConnection) bool {
return true
},
)
}
}

View file

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

View file

@ -1,84 +0,0 @@
package chat
import (
"chat/internal/db"
"context"
"fmt"
"github.com/google/uuid"
"github.com/maddalax/htmgo/framework/service"
"log"
"time"
)
type Message struct {
UserId int64 `json:"userId"`
UserName string `json:"userName"`
Message string `json:"message"`
CreatedAt time.Time `json:"createdAt"`
}
type Service struct {
queries *db.Queries
}
func NewService(locator *service.Locator) *Service {
return &Service{
queries: service.Get[db.Queries](locator),
}
}
func (s *Service) InsertMessage(user *db.User, roomId string, message string) *Message {
err := s.queries.InsertMessage(context.Background(), db.InsertMessageParams{
UserID: user.ID,
Username: user.Name,
ChatRoomID: roomId,
Message: message,
})
if err != nil {
log.Printf("Failed to insert message: %v\n", err)
return nil
}
return &Message{
UserId: user.ID,
UserName: user.Name,
Message: message,
CreatedAt: time.Now(),
}
}
func (s *Service) GetUserBySession(sessionId string) (*db.User, error) {
user, err := s.queries.GetUserBySessionId(context.Background(), sessionId)
return &user, err
}
func (s *Service) CreateUser(name string) (*db.CreateUserRow, error) {
nameWithHash := fmt.Sprintf("%s#%s", name, uuid.NewString()[0:4])
sessionId := fmt.Sprintf("session-%s-%s", uuid.NewString(), uuid.NewString())
user, err := s.queries.CreateUser(context.Background(), db.CreateUserParams{
Name: nameWithHash,
SessionID: sessionId,
})
if err != nil {
return nil, err
}
return &user, nil
}
func (s *Service) CreateRoom(name string) (*db.CreateChatRoomRow, error) {
room, err := s.queries.CreateChatRoom(context.Background(), db.CreateChatRoomParams{
ID: fmt.Sprintf("room-%s-%s", uuid.NewString()[0:8], name),
Name: name,
})
if err != nil {
return nil, err
}
return &room, nil
}
func (s *Service) GetRoom(id string) (*db.ChatRoom, error) {
room, err := s.queries.GetChatRoom(context.Background(), id)
if err != nil {
return nil, err
}
return &room, nil
}

View file

@ -1,57 +0,0 @@
package components
import "github.com/maddalax/htmgo/framework/h"
type ButtonProps struct {
Id string
Text string
Target string
Type string
Trigger string
Get string
Class string
Children []h.Ren
}
func PrimaryButton(props ButtonProps) h.Ren {
props.Class = h.MergeClasses(props.Class, "border-slate-800 bg-slate-900 hover:bg-slate-800 text-white")
return Button(props)
}
func SecondaryButton(props ButtonProps) h.Ren {
props.Class = h.MergeClasses(props.Class, "border-gray-700 bg-gray-700 text-white")
return Button(props)
}
func Button(props ButtonProps) h.Ren {
text := h.Text(props.Text)
button := h.Button(
h.If(
props.Id != "",
h.Id(props.Id),
),
h.If(
props.Children != nil,
h.Children(props.Children...),
),
h.Class("flex gap-1 items-center justify-center border p-4 rounded cursor-hover", props.Class),
h.If(
props.Get != "",
h.Get(props.Get),
),
h.If(
props.Target != "",
h.HxTarget(props.Target),
),
h.IfElse(
props.Type != "",
h.Type(props.Type),
h.Type("button"),
),
text,
)
return button
}

View file

@ -1,14 +0,0 @@
package components
import "github.com/maddalax/htmgo/framework/h"
func FormError(error string) *h.Element {
return h.Div(
h.Id("form-error"),
h.Text(error),
h.If(
error != "",
h.Class("p-4 bg-rose-400 text-white rounded"),
),
)
}

View file

@ -1,81 +0,0 @@
package components
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/hx"
)
type InputProps struct {
Id string
Label string
Name string
Type string
DefaultValue string
Placeholder string
Required bool
ValidationPath string
Error string
Children []h.Ren
}
func Input(props InputProps) *h.Element {
validation := h.If(
props.ValidationPath != "",
h.Children(
h.Post(props.ValidationPath, hx.BlurEvent),
h.Attribute("hx-swap", "innerHTML transition:true"),
h.Attribute("hx-target", "next div"),
),
)
if props.Type == "" {
props.Type = "text"
}
input := h.Input(
props.Type,
h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"),
h.If(
props.Name != "",
h.Name(props.Name),
),
h.If(
props.Children != nil,
h.Children(props.Children...),
),
h.If(
props.Required,
h.Required(),
),
h.If(
props.Placeholder != "",
h.Placeholder(props.Placeholder),
),
h.If(
props.DefaultValue != "",
h.Attribute("value", props.DefaultValue),
),
validation,
)
wrapped := h.Div(
h.If(
props.Id != "",
h.Id(props.Id),
),
h.Class("flex flex-col gap-1"),
h.If(
props.Label != "",
h.Label(
h.Text(props.Label),
),
),
input,
h.Div(
h.Id(props.Id+"-error"),
h.Class("text-red-500"),
),
)
return wrapped
}

View file

@ -1,11 +0,0 @@
module chat
go 1.23.0
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/mattn/go-sqlite3 v1.14.23
github.com/puzpuzpuz/xsync/v3 v3.4.0
)

View file

@ -1,20 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

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

View file

@ -1,35 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package db
import (
"database/sql"
)
type ChatRoom struct {
ID string
Name string
LastMessageSentAt sql.NullString
CreatedAt string
UpdatedAt string
}
type Message struct {
ID int64
ChatRoomID string
UserID int64
Username string
Message string
CreatedAt string
UpdatedAt string
}
type User struct {
ID int64
Name string
CreatedAt string
UpdatedAt string
SessionID string
}

View file

@ -1,25 +0,0 @@
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:chat.db?cache=shared&_fk=1")
if err != nil {
panic(err)
}
if _, err := db.ExecContext(context.Background(), ddl); err != nil {
panic(err)
}
return New(db)
}

View file

@ -1,47 +0,0 @@
-- name: CreateChatRoom :one
INSERT INTO chat_rooms (id, name, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, name, created_at, updated_at, last_message_sent_at;
-- name: InsertMessage :exec
INSERT INTO messages (chat_room_id, user_id, username, message, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, chat_room_id, user_id, username, message, created_at, updated_at;
-- name: UpdateChatRoomLastMessageSentAt :exec
UPDATE chat_rooms
SET last_message_sent_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: GetChatRoom :one
SELECT
id,
name,
last_message_sent_at,
created_at,
updated_at
FROM chat_rooms
WHERE chat_rooms.id = ?;
-- name: CreateUser :one
INSERT INTO users (name, session_id, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, name, session_id, created_at, updated_at;
-- name: GetLastMessages :many
SELECT
messages.id,
messages.chat_room_id,
messages.user_id,
users.name AS user_name,
messages.message,
messages.created_at,
messages.updated_at
FROM messages
JOIN users ON messages.user_id = users.id
WHERE messages.chat_room_id = ?
ORDER BY messages.created_at
LIMIT ?;
-- name: GetUserBySessionId :one
SELECT * FROM users WHERE session_id = ?;

View file

@ -1,212 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: queries.sql
package db
import (
"context"
"database/sql"
)
const createChatRoom = `-- name: CreateChatRoom :one
INSERT INTO chat_rooms (id, name, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, name, created_at, updated_at, last_message_sent_at
`
type CreateChatRoomParams struct {
ID string
Name string
}
type CreateChatRoomRow struct {
ID string
Name string
CreatedAt string
UpdatedAt string
LastMessageSentAt sql.NullString
}
func (q *Queries) CreateChatRoom(ctx context.Context, arg CreateChatRoomParams) (CreateChatRoomRow, error) {
row := q.db.QueryRowContext(ctx, createChatRoom, arg.ID, arg.Name)
var i CreateChatRoomRow
err := row.Scan(
&i.ID,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastMessageSentAt,
)
return i, err
}
const createUser = `-- name: CreateUser :one
INSERT INTO users (name, session_id, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, name, session_id, created_at, updated_at
`
type CreateUserParams struct {
Name string
SessionID string
}
type CreateUserRow struct {
ID int64
Name string
SessionID string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
row := q.db.QueryRowContext(ctx, createUser, arg.Name, arg.SessionID)
var i CreateUserRow
err := row.Scan(
&i.ID,
&i.Name,
&i.SessionID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getChatRoom = `-- name: GetChatRoom :one
SELECT
id,
name,
last_message_sent_at,
created_at,
updated_at
FROM chat_rooms
WHERE chat_rooms.id = ?
`
func (q *Queries) GetChatRoom(ctx context.Context, id string) (ChatRoom, error) {
row := q.db.QueryRowContext(ctx, getChatRoom, id)
var i ChatRoom
err := row.Scan(
&i.ID,
&i.Name,
&i.LastMessageSentAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getLastMessages = `-- name: GetLastMessages :many
SELECT
messages.id,
messages.chat_room_id,
messages.user_id,
users.name AS user_name,
messages.message,
messages.created_at,
messages.updated_at
FROM messages
JOIN users ON messages.user_id = users.id
WHERE messages.chat_room_id = ?
ORDER BY messages.created_at
LIMIT ?
`
type GetLastMessagesParams struct {
ChatRoomID string
Limit int64
}
type GetLastMessagesRow struct {
ID int64
ChatRoomID string
UserID int64
UserName string
Message string
CreatedAt string
UpdatedAt string
}
func (q *Queries) GetLastMessages(ctx context.Context, arg GetLastMessagesParams) ([]GetLastMessagesRow, error) {
rows, err := q.db.QueryContext(ctx, getLastMessages, arg.ChatRoomID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetLastMessagesRow
for rows.Next() {
var i GetLastMessagesRow
if err := rows.Scan(
&i.ID,
&i.ChatRoomID,
&i.UserID,
&i.UserName,
&i.Message,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserBySessionId = `-- name: GetUserBySessionId :one
SELECT id, name, created_at, updated_at, session_id FROM users WHERE session_id = ?
`
func (q *Queries) GetUserBySessionId(ctx context.Context, sessionID string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserBySessionId, sessionID)
var i User
err := row.Scan(
&i.ID,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.SessionID,
)
return i, err
}
const insertMessage = `-- name: InsertMessage :exec
INSERT INTO messages (chat_room_id, user_id, username, message, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, chat_room_id, user_id, username, message, created_at, updated_at
`
type InsertMessageParams struct {
ChatRoomID string
UserID int64
Username string
Message string
}
func (q *Queries) InsertMessage(ctx context.Context, arg InsertMessageParams) error {
_, err := q.db.ExecContext(ctx, insertMessage,
arg.ChatRoomID,
arg.UserID,
arg.Username,
arg.Message,
)
return err
}
const updateChatRoomLastMessageSentAt = `-- name: UpdateChatRoomLastMessageSentAt :exec
UPDATE chat_rooms
SET last_message_sent_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
func (q *Queries) UpdateChatRoomLastMessageSentAt(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, updateChatRoomLastMessageSentAt, id)
return err
}

View file

@ -1,33 +0,0 @@
CREATE TABLE IF NOT EXISTS users
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
session_id TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS chat_rooms
(
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
last_message_sent_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE TABLE IF NOT EXISTS messages
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_room_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (chat_room_id) REFERENCES chat_rooms (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
) STRICT;
CREATE INDEX IF NOT EXISTS idx_messages_chat_room_id ON messages (chat_room_id);
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages (user_id);

View file

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

View file

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

View file

@ -1,54 +0,0 @@
package main
import (
"chat/__htmgo"
"chat/chat"
"chat/internal/db"
"chat/sse"
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
"runtime"
"time"
)
func main() {
locator := service.NewLocator()
service.Set[db.Queries](locator, service.Singleton, db.Provide)
service.Set[sse.SocketManager](locator, service.Singleton, func() *sse.SocketManager {
return sse.NewSocketManager()
})
chatManager := chat.NewManager(locator)
go chatManager.StartListener()
go func() {
for {
count := runtime.NumGoroutine()
fmt.Printf("goroutines: %d\n", count)
time.Sleep(10 * time.Second)
}
}()
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
app.Router.Handle("/sse/chat/{id}", sse.Handle())
__htmgo.Register(app.Router)
},
})
}

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