lots of routign stuff

This commit is contained in:
maddalax 2024-09-10 19:52:18 -05:00
parent 2514708522
commit 35ee5959b3
34 changed files with 1891 additions and 222 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"]
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

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="dataSourceStorageLocal" created-in="GO-233.13135.104"> <component name="dataSourceStorageLocal" created-in="GO-242.21829.165">
<data-source name="site.db" uuid="6b63d5bd-e451-4904-b659-21db5c54c16d"> <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"> <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> <identifier-quote-string>&quot;</identifier-quote-string>

View file

@ -1,9 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<dataSource name="site.db"> <dataSource name="site.db">
<database-model serializer="dbm" dbms="SQLITE" family-id="SQLITE" format-version="4.51"> <database-model serializer="dbm" dbms="SQLITE" family-id="SQLITE" format-version="4.53">
<root id="1"> <root id="1"/>
<ServerVersion>3.40.1</ServerVersion>
</root>
<collation id="2" parent="1" name="RTRIM"/> <collation id="2" parent="1" name="RTRIM"/>
<collation id="3" parent="1" name="NOCASE"/> <collation id="3" parent="1" name="NOCASE"/>
<collation id="4" parent="1" name="BINARY"/> <collation id="4" parent="1" name="BINARY"/>
@ -1428,34 +1426,34 @@
</table> </table>
<table id="505" parent="173" name="users"/> <table id="505" parent="173" name="users"/>
<column id="506" parent="504" name="type"> <column id="506" parent="504" name="type">
<DasType>TEXT|0s</DasType>
<Position>1</Position> <Position>1</Position>
<StoredType>TEXT|0s</StoredType>
</column> </column>
<column id="507" parent="504" name="name"> <column id="507" parent="504" name="name">
<DasType>TEXT|0s</DasType>
<Position>2</Position> <Position>2</Position>
<StoredType>TEXT|0s</StoredType>
</column> </column>
<column id="508" parent="504" name="tbl_name"> <column id="508" parent="504" name="tbl_name">
<DasType>TEXT|0s</DasType>
<Position>3</Position> <Position>3</Position>
<StoredType>TEXT|0s</StoredType>
</column> </column>
<column id="509" parent="504" name="rootpage"> <column id="509" parent="504" name="rootpage">
<DasType>INT|0s</DasType>
<Position>4</Position> <Position>4</Position>
<StoredType>INT|0s</StoredType>
</column> </column>
<column id="510" parent="504" name="sql"> <column id="510" parent="504" name="sql">
<DasType>TEXT|0s</DasType>
<Position>5</Position> <Position>5</Position>
<StoredType>TEXT|0s</StoredType>
</column> </column>
<column id="511" parent="505" name="id"> <column id="511" parent="505" name="id">
<DasType>text|0s</DasType>
<NotNull>1</NotNull> <NotNull>1</NotNull>
<Position>1</Position> <Position>1</Position>
<StoredType>text|0s</StoredType>
</column> </column>
<column id="512" parent="505" name="name"> <column id="512" parent="505" name="name">
<DasType>text|0s</DasType>
<NotNull>1</NotNull> <NotNull>1</NotNull>
<Position>2</Position> <Position>2</Position>
<StoredType>text|0s</StoredType>
</column> </column>
<index id="513" parent="505" name="sqlite_autoindex_users_1"> <index id="513" parent="505" name="sqlite_autoindex_users_1">
<ColNames>id</ColNames> <ColNames>id</ColNames>

View file

@ -35,6 +35,12 @@ func Connect() *redis.Client {
return rdb 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 { func Set[T any](key string, value T) error {
db := Connect() db := Connect()
serialized, err := json.Marshal(value) serialized, err := json.Marshal(value)
@ -55,6 +61,45 @@ func HSet[T any](set string, key string, value T) error {
return result.Err() 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) { func Get[T any](key string) (*T, error) {
db := Connect() db := Connect()
val, err := db.Get(context.Background(), key).Result() val, err := db.Get(context.Background(), key).Result()

30
go.mod
View file

@ -3,26 +3,24 @@ module mhtml
go 1.20 go 1.20
require ( require (
github.com/andybalholm/brotli v1.0.5 // indirect github.com/fsnotify/fsnotify v1.7.0
github.com/gofiber/fiber/v2 v2.52.4
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.0.5
golang.org/x/tools v0.4.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gofiber/fiber/v2 v2.47.0 // indirect github.com/klauspost/compress v1.17.7 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/klauspost/compress v1.16.3 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/philhofer/fwd v1.1.2 // indirect
github.com/redis/go-redis/v9 v9.0.5 // indirect
github.com/rivo/uniseg v0.2.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect
github.com/samber/lo v1.38.1 // indirect
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.47.0 // indirect github.com/valyala/fasthttp v1.52.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect golang.org/x/sys v0.18.0 // indirect
golang.org/x/sys v0.9.0 // indirect
) )

100
go.sum
View file

@ -1,103 +1,39 @@
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c= github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4= golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

71
h/app.go Normal file
View file

@ -0,0 +1,71 @@
package h
import (
"github.com/gofiber/fiber/v2"
)
type App struct {
LiveReload bool
Fiber *fiber.App
}
var instance *App
func GetApp() *App {
if instance == nil {
panic("App instance not initialized")
}
return instance
}
func Start(app *fiber.App, opts App) {
instance = &opts
instance.start(app)
}
func (a App) start(app *fiber.App) {
a.Fiber = app
if a.LiveReload {
AddLiveReloadHandler("/livereload", a.Fiber)
}
err := a.Fiber.Listen(":3000")
if err != nil {
panic(err)
}
}
func HtmlView(c *fiber.Ctx, page *Page) error {
root := page.Root
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
if GetApp().LiveReload && root.tag == "html" {
root.AppendChild(
LiveReload(),
)
}
return c.SendString(
Render(
root,
),
)
}
func PartialView(c *fiber.Ctx, partial *Partial) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
if partial.Headers != nil {
for s, a := range *partial.Headers {
c.Set(s, a)
}
}
return c.SendString(
Render(
partial.Root,
),
)
}

59
h/base.go Normal file
View file

@ -0,0 +1,59 @@
package h
import (
"github.com/gofiber/fiber/v2"
"net/http"
"reflect"
"runtime"
)
type Headers = map[string]string
type Partial struct {
Headers *Headers
Root *Node
}
type Page struct {
Root *Node
HttpMethod string
}
func NewPage(root *Node) *Page {
return &Page{
HttpMethod: http.MethodGet,
Root: root,
}
}
func NewPageWithHttpMethod(httpMethod string, root *Node) *Page {
return &Page{
HttpMethod: httpMethod,
Root: root,
}
}
func NewPartialWithHeaders(headers *Headers, root *Node) *Partial {
return &Partial{
Headers: headers,
Root: root,
}
}
func NewPartial(root *Node) *Partial {
return &Partial{
Root: root,
}
}
func GetFunctionName(i interface{}) string {
return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()
}
func GetPartialPath(partial func(ctx *fiber.Ctx) *Partial) string {
return GetFunctionName(partial)
}
func GetPartialPathWithQs(partial func(ctx *fiber.Ctx) *Partial, qs string) string {
return GetPartialPath(partial) + "?" + qs
}

33
h/livereload.go Normal file
View file

@ -0,0 +1,33 @@
package h
import (
"github.com/gofiber/fiber/v2"
"strconv"
"time"
)
var Version = time.Now().Nanosecond()
func LiveReloadHandler(c *fiber.Ctx) error {
v := strconv.FormatInt(int64(Version), 10)
current := c.Cookies("version", v)
if current != v {
c.Set("HX-Refresh", "true")
}
c.Cookie(&fiber.Cookie{
Name: "version",
Value: v,
})
return c.SendString("")
}
func LiveReload() *Node {
return Div(Get("/livereload"), Trigger("every 100ms"))
}
func AddLiveReloadHandler(path string, app *fiber.App) {
app.Get(path, LiveReloadHandler)
}

View file

@ -3,9 +3,13 @@ package h
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
) )
const FlagSkip = "skip" const FlagSkip = "skip"
const FlagText = "text"
const FlagRaw = "raw"
const FlagAttributeList = "attribute-list"
type Builder struct { type Builder struct {
builder *strings.Builder builder *strings.Builder
@ -35,11 +39,26 @@ func (page Builder) renderNode(node *Node) {
} }
for _, child := range node.children { for _, child := range node.children {
if child == nil {
continue
}
if child.tag == "class" { if child.tag == "class" {
insertAttribute(node, "class", child.value) insertAttribute(node, "class", child.value)
child.tag = FlagSkip child.tag = FlagSkip
} }
if child.tag == FlagAttributeList {
for _, gc := range child.children {
for key, value := range gc.attributes {
insertAttribute(node, key, value)
}
gc.tag = FlagSkip
}
child.tag = FlagSkip
}
if child.tag == "attribute" { if child.tag == "attribute" {
for key, value := range child.attributes { for key, value := range child.attributes {
insertAttribute(node, key, value) insertAttribute(node, key, value)
@ -68,6 +87,19 @@ func (page Builder) renderNode(node *Node) {
} }
} }
for _, child := range node.children { for _, child := range node.children {
if child == nil {
continue
}
if child.tag == FlagText {
page.builder.WriteString(child.text)
continue
}
if child.tag == FlagRaw {
page.builder.WriteString(child.value)
continue
}
if child.tag != FlagSkip { if child.tag != FlagSkip {
page.renderNode(child) page.renderNode(child)
} }
@ -78,6 +110,7 @@ func (page Builder) renderNode(node *Node) {
} }
func Render(node *Node) string { func Render(node *Node) string {
start := time.Now()
builder := strings.Builder{} builder := strings.Builder{}
page := Builder{ page := Builder{
builder: &builder, builder: &builder,
@ -85,5 +118,7 @@ func Render(node *Node) string {
} }
page.render() page.render()
d := page.builder.String() d := page.builder.String()
duration := time.Since(start)
fmt.Printf("render took %s\n", duration)
return d return d
} }

35
h/state.go Normal file
View file

@ -0,0 +1,35 @@
package h
import (
"fmt"
"github.com/gofiber/fiber/v2"
"mhtml/database"
)
func SessionSet(ctx *fiber.Ctx, key string, value string) error {
sessionId := getSessionId(ctx)
if sessionId == "" {
return nil
}
return database.HSet(fmt.Sprintf("session:%s", sessionId), key, value)
}
func SessionIncr(ctx *fiber.Ctx, key string) int64 {
sessionId := getSessionId(ctx)
if sessionId == "" {
return 0
}
return database.HIncr(fmt.Sprintf("session:%s", sessionId), key)
}
func SessionGet[T any](ctx *fiber.Ctx, key string) *T {
sessionId := getSessionId(ctx)
if sessionId == "" {
return nil
}
return database.HGet[T](fmt.Sprintf("session:%s", sessionId), key)
}
func getSessionId(ctx *fiber.Ctx) string {
return ctx.Cookies("mhtml-session")
}

168
h/tag.go
View file

@ -1,20 +1,80 @@
package h package h
import (
"encoding/json"
"github.com/gofiber/fiber/v2"
"html"
"net/url"
"strings"
)
type Node struct { type Node struct {
id string
tag string tag string
attributes map[string]string attributes map[string]string
children []*Node children []*Node
text string text string
value string value string
changed bool
} }
func Class(value string) *Node { func NewNode(tag string) Node {
return Node{
tag: tag,
attributes: nil,
children: nil,
text: "",
value: "",
id: "",
}
}
type Action struct {
Type string
Target *Node
Value any
}
func (node *Node) AppendChild(child *Node) *Node {
node.children = append(node.children, child)
return node
}
func (node *Node) SetChanged(changed bool) *Node {
node.changed = changed
return node
}
func Data(data map[string]any) *Node {
serialized, err := json.Marshal(data)
if err != nil {
return Empty()
}
return Attribute("x-data", string(serialized))
}
func ClassIf(condition bool, value string) *Node {
if condition {
return Class(value)
}
return Empty()
}
func Class(value ...string) *Node {
return &Node{ return &Node{
tag: "class", tag: "class",
value: value, value: MergeClasses(value...),
} }
} }
func MergeClasses(classes ...string) string {
builder := ""
for _, s := range classes {
builder += s + " "
}
return builder
}
func Id(value string) *Node { func Id(value string) *Node {
return Attribute("id", value) return Attribute("id", value)
} }
@ -28,8 +88,43 @@ func Attribute(key string, value string) *Node {
} }
} }
func Get(url string) *Node { func Get(path string) *Node {
return Attribute("hx-get", url) return Attribute("hx-get", path)
}
func CreateTriggers(triggers ...string) []string {
return triggers
}
type ReloadParams struct {
Triggers []string
}
func View(partial func(ctx *fiber.Ctx) *Partial, params ReloadParams) *Node {
return &Node{
tag: "attribute",
attributes: map[string]string{
"hx-get": GetPartialPath(partial),
"hx-trigger": strings.Join(params.Triggers, ", "),
},
}
}
func GetWithQs(path string, qs map[string]string) *Node {
u, err := url.Parse(path)
if err != nil {
return Empty()
}
q := u.Query()
for s := range qs {
q.Add(s, qs[s])
}
u.RawQuery = q.Encode()
return Get(u.String())
} }
func Post(url string) *Node { func Post(url string) *Node {
@ -40,6 +135,13 @@ func Trigger(trigger string) *Node {
return Attribute("hx-trigger", trigger) return Attribute("hx-trigger", trigger)
} }
func Text(text string) *Node {
return &Node{
tag: "text",
text: text,
}
}
func Target(target string) *Node { func Target(target string) *Node {
return Attribute("hx-target", target) return Attribute("hx-target", target)
} }
@ -72,7 +174,11 @@ func Click(value string) *Node {
return Attribute("onclick", value) return Attribute("onclick", value)
} }
func Page(children ...*Node) *Node { func OnClickWs(handler string) *Node {
return Attribute("data-ws-click", handler)
}
func Html(children ...*Node) *Node {
return &Node{ return &Node{
tag: "html", tag: "html",
children: children, children: children,
@ -103,6 +209,18 @@ func Script(url string) *Node {
} }
} }
func Raw(text string) *Node {
return &Node{
tag: "raw",
children: make([]*Node, 0),
value: text,
}
}
func RawScript(text string) *Node {
return Raw("<script>" + text + "</script>")
}
func Div(children ...*Node) *Node { func Div(children ...*Node) *Node {
return &Node{ return &Node{
tag: "div", tag: "div",
@ -158,6 +276,19 @@ func Fragment(children ...*Node) *Node {
} }
} }
func AttributeList(children ...*Node) *Node {
return &Node{
tag: FlagAttributeList,
children: children,
}
}
func AppendChildren(node *Node, children ...*Node) *Node {
node.children = append(node.children, children...)
return node
}
func Button(children ...*Node) *Node { func Button(children ...*Node) *Node {
return &Node{ return &Node{
tag: "button", tag: "button",
@ -165,6 +296,10 @@ func Button(children ...*Node) *Node {
} }
} }
func Indicator(tag string) *Node {
return Attribute("hx-indicator", tag)
}
func P(text string, children ...*Node) *Node { func P(text string, children ...*Node) *Node {
return &Node{ return &Node{
tag: "p", tag: "p",
@ -187,6 +322,16 @@ func Empty() *Node {
} }
} }
func BeforeRequestSetHtml(children ...*Node) *Node {
serialized := Render(Fragment(children...))
return Attribute("hx-on::before-request", `this.innerHTML = '`+html.EscapeString(serialized)+`'`)
}
func AfterRequestSetHtml(children ...*Node) *Node {
serialized := Render(Fragment(children...))
return Attribute("hx-on::after-request", `this.innerHTML = '`+html.EscapeString(serialized)+`'`)
}
func If(condition bool, node *Node) *Node { func If(condition bool, node *Node) *Node {
if condition { if condition {
return node return node
@ -195,6 +340,19 @@ func If(condition bool, node *Node) *Node {
} }
} }
func JsIf(condition string, node *Node) *Node {
node1 := &Node{tag: "template"}
node1.AppendChild(Attribute("x-if", condition))
node1.AppendChild(node)
return node
}
func JsIfElse(condition string, node *Node, node2 *Node) *Node {
node1Template := Div(Attribute("x-show", condition), node)
node2Template := Div(Attribute("x-show", "!("+condition+")"), node2)
return Fragment(node1Template, node2Template)
}
func IfElse(condition bool, node *Node, node2 *Node) *Node { func IfElse(condition bool, node *Node, node2 *Node) *Node {
if condition { if condition {
return node return node

View file

@ -2,6 +2,8 @@ package httpjson
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"io" "io"
"net/http" "net/http"
"sync" "sync"
@ -21,10 +23,6 @@ func getClient() *http.Client {
} }
httpClient := &http.Client{ httpClient := &http.Client{
Transport: tr, Transport: tr,
// do not follow redirects
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
} }
client = httpClient client = httpClient
}) })
@ -43,6 +41,10 @@ func Get[T any](url string) (T, error) {
resp.Body.Close() resp.Body.Close()
}() }()
if resp.StatusCode > 299 {
return *new(T), errors.New(fmt.Sprintf("get to %s failed with %d code", url, resp.StatusCode))
}
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return *new(T), err return *new(T), err
@ -52,5 +54,10 @@ func Get[T any](url string) (T, error) {
if err != nil { if err != nil {
return *new(T), err return *new(T), err
} }
if d == nil {
return *new(T), errors.New("failed to create T")
}
return *d, nil return *d, nil
} }

View file

@ -1,24 +0,0 @@
<html>
<head>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>
<body>
<div class="flex flex-col gap-2 flex flex-col gap-2 bg-gray-100 h-full w-full items-center p-12">
<div>
<p>Log in
<button></button>
</p>
<div class="flex flex-col gap-2 items-center"><p>Sydne Anschutz</p>
<p>sanschutz0808@gmail.com</p>
<button class="bg-red-200 rounded p-2" onclick="alert(1)"><p>Delete</p></button>
</div>
<div class="flex flex-col gap-2 items-center"><p>Maddy</p>
<p>jm@madev.me</p>
<button class="bg-red-200 rounded p-2" onclick="alert(1)"><p>Delete</p></button>
</div>
</div>
<div><p>Enter name</p><input type="text" class="rounded p-4 border-gray-100"></input></div>
</div>
</body>
</html>

10
js/mhtml.js Normal file
View file

@ -0,0 +1,10 @@
// Replace 'ws://example.com/socket' with the URL of your WebSocket server
window.onload = function () {
document.querySelectorAll('[m\\:onclick]').forEach(element => {
const value = element.getAttribute('m:onclick')
element.addEventListener('click', () => {
fetch('/click/' + value).catch()
})
});
}

9
justfile Normal file
View file

@ -0,0 +1,9 @@
# Command to run and watch the Go application using Air
run-app:
air
run-gen:
go run ./tooling/astgen
watch-gen:
go run ./tooling/watch.go --command 'go run ./tooling/astgen'

95
main.go
View file

@ -2,85 +2,36 @@ package main
import ( import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"mhtml/database" "github.com/google/uuid"
"mhtml/h" "mhtml/h"
"mhtml/news" "mhtml/pages"
"strconv" "mhtml/partials"
"time"
) )
type User struct {
Name string
Email string
}
var Version = time.Now().Nanosecond()
func LiveReloadHandler(c *fiber.Ctx) error {
v := strconv.FormatInt(int64(Version), 10)
current := c.Cookies("version", v)
if current != v {
c.Set("HX-Refresh", "true")
}
c.Cookie(&fiber.Cookie{
Name: "version",
Value: v,
})
return c.SendString("")
}
func Page(children ...*h.Node) *h.Node {
return h.Page(
h.Head(
h.Script("https://cdn.tailwindcss.com"),
h.Script("https://unpkg.com/htmx.org@1.9.2"),
),
h.Body(
h.VStack(
h.Class("flex flex-col gap-2 bg-gray-100 h-full w-full items-center p-12"),
h.Fragment(children...),
),
),
)
}
func IndexPage(c *fiber.Ctx) error {
page := Page(
h.Div(
h.P("Hacker News - Top Stories"),
),
news.StoryList(),
)
return HtmlView(c, page)
}
func HtmlView(c *fiber.Ctx, child *h.Node) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
return c.SendString(
h.Render(
child,
),
)
}
func main() { func main() {
f := fiber.New()
f.Static("/js", "./js")
database.HSet("users", "sydne", User{ f.Use(func(ctx *fiber.Ctx) error {
Name: "Sydne Anschutz", if ctx.Cookies("mhtml-session") != "" {
Email: "sanschutz0808@gmail.com", return ctx.Next()
}
id := ctx.IP() + uuid.NewString()
ctx.Cookie(&fiber.Cookie{
Name: "mhtml-session",
Value: id,
SessionOnly: true,
})
return ctx.Next()
}) })
app := fiber.New() f.Get("/mhtml/partials.*", func(ctx *fiber.Ctx) error {
return h.PartialView(ctx, partials.GetPartialFromContext(ctx))
app.Get("/", IndexPage)
app.Get("/news/:id", func(c *fiber.Ctx) error {
return HtmlView(c, Page(
news.StoryFull(c.Params("id")),
))
}) })
app.Get("/livereload", LiveReloadHandler)
app.Listen(":3000") pages.RegisterPages(f)
h.Start(f, h.App{
LiveReload: true,
})
} }

View file

@ -2,22 +2,23 @@ package news
import ( import (
"fmt" "fmt"
"mhtml/database"
"mhtml/h" "mhtml/h"
) )
func StoryList() *h.Node { func StoryList() *h.Node {
posts, err := List()
if err != nil { posts, _ := database.GetOrSet[[]Post]("posts", func() []Post {
return h.P(err.Error()) p, _ := List()
} return p
})
if len(posts) == 0 { if len(*posts) == 0 {
return h.P("No results found") return h.P("No results found")
} }
return h.Fragment( return h.Fragment(
h.VStack(h.List(posts, func(item Post) *h.Node { h.VStack(h.List(*posts, func(item Post) *h.Node {
return StoryCard(item) return StoryCard(item)
})), })),
) )
@ -26,7 +27,7 @@ func StoryList() *h.Node {
func StoryCard(post Post) *h.Node { func StoryCard(post Post) *h.Node {
url := fmt.Sprintf("/news/%d", post.Id) url := fmt.Sprintf("/news/%d", post.Id)
return h.VStack( return h.VStack(
h.Class("items-center bg-red-200 p-4 rounded"), h.Class("items-center bg-indigo-200 p-4 rounded"),
h.A(post.Title, h.Href(url)), h.A(post.Title, h.Href(url)),
) )
} }

22
pages/base/root.go Normal file
View file

@ -0,0 +1,22 @@
package base
import (
"mhtml/h"
)
func RootPage(children ...*h.Node) *h.Node {
return h.Html(
h.Head(
h.Script("https://cdn.tailwindcss.com"),
h.Script("https://unpkg.com/htmx.org@1.9.12"),
h.Script("https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"),
h.Script("/js/mhtml.js"),
),
h.Body(
h.VStack(
h.Class("flex flex-col gap-2 bg-gray-100 h-full"),
h.Fragment(children...),
),
),
)
}

17
pages/generated.go Normal file
View file

@ -0,0 +1,17 @@
// Package pages THIS FILE IS GENERATED. DO NOT EDIT.
package pages
import "github.com/gofiber/fiber/v2"
import "mhtml/h"
func RegisterPages(f *fiber.App) {
f.Get("/", func(ctx *fiber.Ctx) error {
return h.HtmlView(ctx, IndexPage(ctx))
})
f.Get("/news/:id", func(ctx *fiber.Ctx) error {
return h.HtmlView(ctx, Test(ctx))
})
f.Get("/news", func(ctx *fiber.Ctx) error {
return h.HtmlView(ctx, ListPage(ctx))
})
}

20
pages/index.go Normal file
View file

@ -0,0 +1,20 @@
package pages
import (
"github.com/gofiber/fiber/v2"
"mhtml/h"
"mhtml/pages/base"
)
func IndexPage(c *fiber.Ctx) *h.Page {
return h.NewPage(base.RootPage(
h.Fragment(
h.Div(
h.Class("inline-flex flex-col gap-4 p-4"),
h.Div(
h.Class("max-w-md flex flex-col gap-4"),
h.P("Routes"),
),
)),
))
}

14
pages/news.$id.go Normal file
View file

@ -0,0 +1,14 @@
package pages
import (
"fmt"
"github.com/gofiber/fiber/v2"
"mhtml/h"
)
func Test(ctx *fiber.Ctx) *h.Page {
text := fmt.Sprintf("News ID: %s", ctx.Params("id"))
return h.NewPage(
h.Div(h.Text(text)),
)
}

42
pages/news.index.go Normal file
View file

@ -0,0 +1,42 @@
package pages
import (
"github.com/gofiber/fiber/v2"
"mhtml/h"
"mhtml/pages/base"
"mhtml/partials"
"mhtml/ui"
)
func ListPage(ctx *fiber.Ctx) *h.Page {
return h.NewPage(base.RootPage(
list(),
))
}
func list() *h.Node {
return h.Fragment(
partials.SheetClosed(),
h.Div(
h.Class("inline-flex flex-col gap-4 p-4"),
h.Div(
h.Class("max-w-md flex flex-col gap-4 "),
openButton(),
),
h.Div(
h.View(partials.SheetOpenCount, h.ReloadParams{
Triggers: h.CreateTriggers("load", "sheetOpened from:body"),
}),
h.Text("you opened sheet 0 times")),
))
}
func openButton() *h.Node {
return h.VStack(
ui.PrimaryButton(ui.ButtonProps{
Text: "Open Sheet",
Target: "#sheet-partial",
Get: h.GetPartialPathWithQs(partials.Sheet, "open=true"),
}),
)
}

1
partials/base.go Normal file
View file

@ -0,0 +1 @@
package partials

16
partials/generated.go Normal file
View file

@ -0,0 +1,16 @@
// Package partials THIS FILE IS GENERATED. DO NOT EDIT.
package partials
import "mhtml/h"
import "github.com/gofiber/fiber/v2"
func GetPartialFromContext(ctx *fiber.Ctx) *h.Partial {
path := ctx.Path()
if path == "SheetOpenCount" || path == "/mhtml/partials.SheetOpenCount" {
return SheetOpenCount(ctx)
}
if path == "Sheet" || path == "/mhtml/partials.Sheet" {
return Sheet(ctx)
}
return nil
}

56
partials/sheet.go Normal file
View file

@ -0,0 +1,56 @@
package partials
import (
"fmt"
"github.com/gofiber/fiber/v2"
"mhtml/h"
"mhtml/news"
"mhtml/ui"
)
func SheetOpenCount(ctx *fiber.Ctx) *h.Partial {
rnd := h.SessionGet[int64](ctx, "sheet-open-count")
if rnd == nil {
rnd = new(int64)
}
return h.NewPartial(h.Div(
h.Text(fmt.Sprintf("you opened sheet %d times", *rnd)),
))
}
func SheetClosed() *h.Node {
return h.Div(h.Id("sheet-partial"))
}
func Sheet(ctx *fiber.Ctx) *h.Partial {
open := ctx.Query("open")
if open == "true" {
h.SessionIncr(ctx, "sheet-open-count")
}
return h.NewPartialWithHeaders(
&map[string]string{
"hx-trigger": "sheetOpened",
},
h.IfElse(open == "true", SheetOpen(), SheetClosed()),
)
}
func SheetOpen() *h.Node {
return h.Div(
h.Class(`fixed top-0 right-0 h-full w-96 bg-gray-100 shadow-lg z-50`),
h.Div(
h.Class("p-4 overflow-y-auto h-full w-full flex flex-col gap-4"),
h.P("My Sheet",
h.Class("text-lg font-bold"),
),
h.P("This is a sheet",
h.Class("text-sm mt-2"),
),
ui.Button(ui.ButtonProps{
Text: "Close Sheet",
Target: "#sheet-partial",
Get: h.GetPartialPathWithQs(Sheet, "open=false"),
}),
news.StoryList(),
))
}

135
tooling/astgen/ast.go Normal file
View file

@ -0,0 +1,135 @@
package main
import (
"bytes"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"golang.org/x/tools/go/ast/astutil"
)
type AstUtil struct {
file *ast.File
}
func NewAstUtil(file *ast.File) *AstUtil {
return &AstUtil{file: file}
}
func (a *AstUtil) Sync(builder *CodeBuilder) {
fset := token.NewFileSet()
// Parse the code string into an AST
f, err := parser.ParseFile(fset, "", builder.String(), 0)
if err != nil {
panic(err)
}
a.file = f
}
func (a *AstUtil) HasMethod(methodName string) bool {
return a.GetMethodByName(methodName) != nil
}
func (a *AstUtil) HasStruct(name string) bool {
for _, decl := range a.file.Decls {
_, ok := decl.(*ast.GenDecl)
if ok {
spec, ok := decl.(*ast.GenDecl).Specs[0].(*ast.TypeSpec)
if ok {
if spec.Name.Name == name {
return true
}
}
}
}
return false
}
func (a *AstUtil) GetMethodByName(name string) *ast.FuncDecl {
for _, decl := range a.file.Decls {
funcDecl, ok := decl.(*ast.FuncDecl)
if ok && funcDecl.Name.Name == name {
return funcDecl
}
}
return nil
}
func (a *AstUtil) GetMethodAsString(methodName string) string {
fdecl := a.GetMethodByName(methodName)
if fdecl == nil {
return ""
}
var buf bytes.Buffer
fset := token.NewFileSet() // Create a FileSet to manage source file positions
// Use printer.Fprint to format the AST node into the buffer
err := printer.Fprint(&buf, fset, fdecl)
if err != nil {
panic(err)
}
return buf.String()
}
type UpdateSignature struct {
MethodName string
NewName string
NewParams []NameType
NewResults []ReturnType
}
func (a *AstUtil) UpdateMethodSignature(req UpdateSignature) {
newParams := Map(req.NewParams, func(nt NameType) *ast.Field {
return nt.ToAst()
})
newResults := Map(req.NewResults, func(rt ReturnType) *ast.Field {
return rt.ToAst()
})
for _, decl := range a.file.Decls {
funcDecl, ok := decl.(*ast.FuncDecl)
if ok && funcDecl.Name.Name == req.MethodName {
// Method found, update its signature
funcDecl.Name = ast.NewIdent(req.NewName)
funcDecl.Type.Params = &ast.FieldList{List: newParams}
funcDecl.Type.Results = &ast.FieldList{List: newResults}
}
}
}
func (a *AstUtil) SetPackageName(name string) {
a.file.Name = ast.NewIdent(name)
}
func (a *AstUtil) AddImport(path string) {
if !astutil.UsesImport(a.file, path) {
astutil.AddImport(token.NewFileSet(), a.file, path)
}
}
func (a *AstUtil) DeleteMethod(methodName string) {
for i, decl := range a.file.Decls {
funcDecl, ok := decl.(*ast.FuncDecl)
if ok && funcDecl.Name.Name == methodName {
// Method found, remove it from the slice
a.file.Decls = append(a.file.Decls[:i], a.file.Decls[i+1:]...)
return
}
}
}
func (a *AstUtil) String() string {
var buf bytes.Buffer
fset := token.NewFileSet()
err := printer.Fprint(&buf, fset, a.file)
if err != nil {
panic(err)
}
return buf.String()
}

View file

@ -0,0 +1,365 @@
package main
import (
"bytes"
"fmt"
"go/ast"
"strings"
"text/template"
)
// ... (Field and Method structs remain the same)
type CodeBuilder struct {
structTemplate *template.Template
methodTemplate *template.Template
toEventTemplate *template.Template
commandSwitchTemplate *template.Template
validateTemplate *template.Template
funcTemplate *template.Template
interfaceTemplate *template.Template
result string
astu *AstUtil
}
type Assignment struct {
Field string
Value string
Operator string
}
type Function struct {
Name string
Parameters []NameType
Return []ReturnType
Body string
}
type Struct struct {
Name string
Fields []NameType
}
type Validation struct {
MethodCall string
}
type Method struct {
StructReceiver string
StructName string
MethodName string
Body string
Parameters []NameType
Return []ReturnType
}
type NameType struct {
Name string
Type string
}
func (nt *NameType) ToAst() *ast.Field {
return &ast.Field{
Names: []*ast.Ident{ast.NewIdent(nt.Name)}, // Create an identifier for the field name
Type: ast.NewIdent(nt.Type), // Create an identifier for the field type
}
}
type ReturnType struct {
Type string
}
func (rt *ReturnType) ToAst() *ast.Field {
return &ast.Field{
Type: ast.NewIdent(rt.Type),
}
}
type Case struct {
CommandType string
CaseBody string
}
func (cb *CodeBuilder) Append(result string) *CodeBuilder {
cb.result += "\n"
cb.result += result
cb.result += "\n"
return cb
}
func (cb *CodeBuilder) String() string {
return cb.result
}
func (cb *CodeBuilder) SyncFromAst() *CodeBuilder {
cb.SetResult(cb.astu.String())
return cb
}
func (cb *CodeBuilder) SyncToAst() *CodeBuilder {
cb.astu.Sync(cb)
return cb
}
func (cb *CodeBuilder) SetResult(result string) *CodeBuilder {
cb.result = result
return cb
}
func NewCodeBuilder(astu *AstUtil) *CodeBuilder {
toEventTemplate, _ := template.New("toEvent").Parse(`
func (c *{{.StructName}}) {{.MethodCall}}() *{{.EventType}} {
var event *{{.EventType}} = &{{.EventType}}{}
{{range .Fields}}
event.{{.Name}} = c.{{.Name}}
{{end}}
return event
}
`)
validationTemplate := template.Must(template.New("validation").Parse(`
func (c *{{.StructName}}) {{.MethodCall}}() error {
{{range .Validations}}if err := {{.MethodCall}}; err != nil {
return err
}
{{end}}
return nil
}
`))
methodTemplate := template.Must(template.New("method").Parse(`
func ({{.StructReceiver}} *{{.StructName}}) {{.MethodCall}}({{range $index, $param := .Parameters}}{{if $index}}, {{end}}{{$param.Name}} {{$param.Type}}{{end}}) ({{range $index, $ret := .Return}}{{if $index}}, {{end}}{{$ret.Type}}{{end}}) {
{{if .Body}}
{{.Body}}
{{else}}
return {{range $index, $ret := .Return}}{{if $index}}, {{end}}nil{{end}}
{{end}}
}
`))
commandSwitchTemplate, _ := template.New("commandSwitch").Parse(`
switch c := command.(type) {
{{range .Cases}}case *{{.CommandType}}:
{{.CaseBody}}
{{end}}
}
`)
structTemplate, _ := template.New("struct").Parse(`
type {{.StructName}} struct {
{{range .Fields}}
{{.Name}} {{.Type}}
{{end}}
}
`)
interfaceTemplate := template.Must(template.New("interface").Parse(`
type {{.Name}} interface {
{{range .Methods}}
{{.Name}}({{range $index, $param := .Parameters}}{{if $index}}, {{end}}{{$param.Name}} {{$param.Type}}{{end}}) {{range $index, $ret := .Return}}{{if $index}}, {{end}}{{$ret.Type}}{{end}}
{{end}}
}
`))
funcTemplateStr := `
func {{ .Name }}({{ range $index, $param := .Parameters }}{{if $index}}, {{end}}{{$param.Name}} {{$param.Type}}{{end}}) ({{range $index, $ret := .Return }}{{if $index}}, {{end}}{{$ret.Type}}{{end}}) {
{{.Body}}
}
`
funcTemplate := template.Must(template.New("function").Funcs(template.FuncMap{
"trimPrefix": strings.TrimPrefix,
}).Parse(funcTemplateStr))
builder := &CodeBuilder{
funcTemplate: funcTemplate,
structTemplate: structTemplate,
interfaceTemplate: interfaceTemplate,
toEventTemplate: toEventTemplate,
methodTemplate: methodTemplate,
validateTemplate: validationTemplate,
commandSwitchTemplate: commandSwitchTemplate,
astu: astu,
}
if astu != nil {
builder.SyncFromAst()
}
return builder
}
func (cb *CodeBuilder) PrependLine(line string) {
cb.result = line + "\n" + cb.result
}
func (cb *CodeBuilder) PrependLineF(format string, args ...interface{}) {
cb.PrependLine(fmt.Sprintf(format, args...))
}
func (cb *CodeBuilder) AppendLine(line string) {
cb.result += line + "\n"
}
func (cb *CodeBuilder) PrependLineIfNotExist(format string) {
if !strings.Contains(cb.result, format) {
cb.PrependLine(format)
}
}
func (cb *CodeBuilder) AppendLineIfNotExist(line string) {
if !strings.Contains(cb.result, line) {
cb.result += line + "\n"
}
}
func (cb *CodeBuilder) HasString(str string) bool {
return strings.Contains(cb.result, str)
}
func (cb *CodeBuilder) HasStringF(str string, args ...interface{}) bool {
return strings.Contains(cb.result, fmt.Sprintf(str, args...))
}
func (cb *CodeBuilder) AppendLineF(format string, args ...interface{}) {
cb.AppendLine(fmt.Sprintf(format, args...))
}
func (cb *CodeBuilder) ExecuteTemplate(template *template.Template, data map[string]any) string {
var buf bytes.Buffer
err := template.Execute(&buf, data)
if err != nil {
panic(err)
}
return removeAllEmptyLines(buf.String())
}
func (cb *CodeBuilder) AddImport(imp string) *CodeBuilder {
line := fmt.Sprintf(`import "%s"`, imp)
if !strings.Contains(cb.result, line) {
cb.AppendLine(line)
}
return cb
}
func (cb *CodeBuilder) BuildAssignment(a Assignment) string {
return a.Field + " " + a.Operator + " " + a.Value
}
func (cb *CodeBuilder) BuildInterface(name string, methods []Function) string {
return cb.ExecuteTemplate(cb.interfaceTemplate, map[string]any{
"Name": name,
"Methods": methods,
})
}
func (cb *CodeBuilder) BuildNewCommandMethod(_struct Struct) string {
return cb.BuildFunction(Function{
Name: fmt.Sprintf("New%s", _struct.Name),
Parameters: _struct.Fields,
Return: []ReturnType{
{Type: fmt.Sprintf("*%s", _struct.Name)},
},
Body: cb.Template(`
result := {{.ReturnType}}{}
{{range .Fields}}result.{{.Name}} = {{.Name}}
{{end}}{{if .Body}}
{{.Body}}{{end}}
return result
`, map[string]any{
"Fields": _struct.Fields,
"ReturnType": "&" + _struct.Name,
}),
})
}
func (cb *CodeBuilder) BuildValidation(structName string, methodName string, validations []Validation) string {
return cb.ExecuteTemplate(cb.validateTemplate, map[string]any{
"StructName": structName,
"MethodCall": methodName,
"Validations": validations,
})
}
func (cb *CodeBuilder) BuildMethod(method Method) string {
data := map[string]any{
"StructReceiver": method.StructReceiver,
"StructName": method.StructName,
"MethodCall": method.MethodName,
"Parameters": method.Parameters,
"Return": method.Return,
}
if method.Body != "" {
data["Body"] = method.Body
}
return cb.ExecuteTemplate(cb.methodTemplate, data)
}
func (cb *CodeBuilder) Template(templateStr string, data map[string]any) string {
t, err := template.New("temp").Parse(templateStr)
if err != nil {
panic(err)
}
return cb.ExecuteTemplate(t, data)
}
func (cb *CodeBuilder) BuildStruct(s Struct) string {
return cb.ExecuteTemplate(cb.structTemplate, map[string]any{
"StructName": s.Name,
"Fields": s.Fields,
})
}
func (cb *CodeBuilder) BuildFunction(f Function) string {
return cb.ExecuteTemplate(cb.funcTemplate, map[string]any{
"Name": f.Name,
"Parameters": f.Parameters,
"Return": f.Return,
"Body": f.Body,
})
}
func (cb *CodeBuilder) BuildCommandSwitch(cases []Case) (string, error) {
var buf bytes.Buffer
err := cb.commandSwitchTemplate.Execute(&buf, map[string]interface{}{
"Cases": cases,
})
if err != nil {
return "", err
}
return buf.String(), nil
}
func removeAllEmptyLines(input string) string {
// Split the input into lines
lines := strings.Split(input, "\n")
// Filter out empty lines
var nonEmptyLines []string
for _, line := range lines {
if strings.TrimSpace(line) != "" {
nonEmptyLines = append(nonEmptyLines, line)
}
}
// Join the non-empty lines back into a string
output := strings.Join(nonEmptyLines, "\n")
return output
}
func Map[T any, U any](slice []T, transform func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = transform(v)
}
return result
}
func FlatMap[T any, U any](slice []T, transform func(T) []U) []U {
var result []U
for _, v := range slice {
result = append(result, transform(v)...)
}
return result
}

268
tooling/astgen/entry.go Normal file
View file

@ -0,0 +1,268 @@
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
)
type Page struct {
Path string
FuncName string
Package string
Import string
}
func findPublicFuncsReturningHPartial(dir string) ([]string, error) {
var functions []string
// 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 == "Partial" {
functions = append(functions, funcDecl.Name.Name)
break
}
}
}
}
}
}
}
}
return true
})
return nil
})
if err != nil {
return nil, err
}
return functions, 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" {
pages = append(pages, Page{
Package: node.Name.Name,
Import: fmt.Sprintf("mhtml/%s", filepath.Dir(path)),
Path: path,
FuncName: funcDecl.Name.Name,
})
break
}
}
}
}
}
}
}
}
return true
})
return nil
})
if err != nil {
return nil, err
}
return pages, nil
}
func buildGetPartialFromContext(builder *CodeBuilder, funcs []string) {
fName := "GetPartialFromContext"
body := `
path := ctx.Path()
`
for _, f := range funcs {
if f == fName {
continue
}
body += fmt.Sprintf(`
if path == "%s" || path == "/mhtml/partials.%s" {
return %s(ctx)
}
`, f, f, f)
}
body += "return nil"
f := Function{
Name: fName,
Parameters: []NameType{
{Name: "ctx", Type: "*fiber.Ctx"},
},
Return: []ReturnType{
{Type: "*h.Partial"},
},
Body: body,
}
builder.Append(builder.BuildFunction(f))
}
func writePartialsFile() {
cwd, _ := os.Getwd()
partialPath := filepath.Join(cwd, "partials")
funcs, err := findPublicFuncsReturningHPartial(partialPath)
if err != nil {
fmt.Println(err)
return
}
builder := NewCodeBuilder(nil)
builder.AppendLine(`// Package partials THIS FILE IS GENERATED. DO NOT EDIT.`)
builder.AppendLine("package partials")
builder.AddImport("mhtml/h")
builder.AddImport("github.com/gofiber/fiber/v2")
buildGetPartialFromContext(builder, funcs)
WriteFile(filepath.Join("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.ReplaceAll(path, "$", ":")
path = strings.ReplaceAll(path, "_", "/")
path = strings.ReplaceAll(path, ".", "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if strings.HasSuffix(path, "/") {
return path[:len(path)-1]
}
return path
}
func writePagesFile() {
builder := NewCodeBuilder(nil)
builder.AppendLine(`// Package pages THIS FILE IS GENERATED. DO NOT EDIT.`)
builder.AppendLine("package pages")
builder.AddImport("github.com/gofiber/fiber/v2")
builder.AddImport("mhtml/h")
pages, _ := findPublicFuncsReturningHPage("pages")
for _, page := range pages {
if page.Import != "" && page.Package != "pages" {
builder.AddImport(page.Import)
}
}
fName := "RegisterPages"
body := `
`
for _, page := range pages {
call := fmt.Sprintf("%s.%s", page.Package, page.FuncName)
if page.Package == "pages" {
call = page.FuncName
}
body += fmt.Sprintf(`
f.Get("%s", func(ctx *fiber.Ctx) error {
return h.HtmlView(ctx, %s(ctx))
})
`, formatRoute(page.Path), call)
}
f := Function{
Name: fName,
Parameters: []NameType{
{Name: "f", Type: "*fiber.App"},
},
Body: body,
}
builder.Append(builder.BuildFunction(f))
WriteFile("pages/generated.go", func(content *ast.File) string {
return builder.String()
})
}
func main() {
writePartialsFile()
writePagesFile()
}

82
tooling/astgen/map.go Normal file
View file

@ -0,0 +1,82 @@
package main
// OrderedMap is a generic data structure that maintains the order of keys.
type OrderedMap[K comparable, V any] struct {
keys []K
values map[K]V
}
// Entries returns the key-value pairs in the order they were added.
func (om *OrderedMap[K, V]) Entries() []struct {
Key K
Value V
} {
entries := make([]struct {
Key K
Value V
}, len(om.keys))
for i, key := range om.keys {
entries[i] = struct {
Key K
Value V
}{
Key: key,
Value: om.values[key],
}
}
return entries
}
// NewOrderedMap creates a new OrderedMap.
func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] {
return &OrderedMap[K, V]{
keys: []K{},
values: make(map[K]V),
}
}
// Set adds or updates a key-value pair in the OrderedMap.
func (om *OrderedMap[K, V]) Set(key K, value V) {
// Check if the key already exists
if _, exists := om.values[key]; !exists {
om.keys = append(om.keys, key) // Append key to the keys slice if it's a new key
}
om.values[key] = value
}
// Get retrieves a value by key.
func (om *OrderedMap[K, V]) Get(key K) (V, bool) {
value, exists := om.values[key]
return value, exists
}
// Keys returns the keys in the order they were added.
func (om *OrderedMap[K, V]) Keys() []K {
return om.keys
}
// Values returns the values in the order of their keys.
func (om *OrderedMap[K, V]) Values() []V {
values := make([]V, len(om.keys))
for i, key := range om.keys {
values[i] = om.values[key]
}
return values
}
// Delete removes a key-value pair from the OrderedMap.
func (om *OrderedMap[K, V]) Delete(key K) {
if _, exists := om.values[key]; exists {
// Remove the key from the map
delete(om.values, key)
// Remove the key from the keys slice
for i, k := range om.keys {
if k == key {
om.keys = append(om.keys[:i], om.keys[i+1:]...)
break
}
}
}
}

22
tooling/astgen/util.go Normal file
View file

@ -0,0 +1,22 @@
package main
import (
"fmt"
)
func PanicF(format string, args ...interface{}) {
panic(fmt.Sprintf(format, args...))
}
func Unique[T any](slice []T, key func(item T) string) []T {
var result []T
seen := make(map[string]bool)
for _, v := range slice {
k := key(v)
if _, ok := seen[k]; !ok {
seen[k] = true
result = append(result, v)
}
}
return result
}

72
tooling/astgen/writer.go Normal file
View file

@ -0,0 +1,72 @@
package main
import (
"go/ast"
"go/format"
"go/parser"
"go/token"
"log"
"os"
"os/exec"
"path/filepath"
)
func WriteFile(path string, cb func(content *ast.File) string) {
currentDir, err := os.Getwd()
path = filepath.Join(currentDir, path)
dir := filepath.Dir(path)
os.MkdirAll(dir, 0755)
bytes, err := os.ReadFile(path)
if err != nil {
_, err = os.Create(path)
bytes = make([]byte, 0)
if err != nil {
PanicF("Failed to create file: %v", err)
}
}
// Create a FileSet to manage source file positions
fset := token.NewFileSet()
// Parse the file into an AST
f, _ := parser.ParseFile(fset, path, nil, parser.AllErrors)
if f == nil {
f = &ast.File{
Name: ast.NewIdent("replacemeplz"), // Set the package name
Decls: []ast.Decl{}, // No declarations initially
}
}
bytes = []byte(cb(f))
formatEnabled := true
if formatEnabled {
bytes, err = format.Source(bytes)
if err != nil {
log.Printf("Failed to format source: %v\n\n%s", err)
data := string(bytes)
println(data)
return
}
}
// Define the file path where you want to save the buffer
cmd := exec.Command("git", "add", path)
err = cmd.Run()
if err != nil {
log.Printf("Failed to run git add: %v\n", err)
}
// Save the buffer to a file
err = os.WriteFile(path, bytes, 0644)
if err != nil {
PanicF("Failed to write buffer to file: %v", err)
}
}

117
tooling/watch.go Normal file
View file

@ -0,0 +1,117 @@
package main
import (
"bytes"
"fmt"
"github.com/fsnotify/fsnotify"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
func main() {
once := false
if len(os.Args) > 1 {
once = os.Args[1] == "--once"
}
command := ""
for i, arg := range os.Args {
if arg == "--command" {
command = os.Args[i+1]
}
}
if command == "" {
panic("command is required")
}
if once {
runCommand(command)
return
}
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from fatal error:", r)
// You can log the error here or take other corrective actions
}
}()
runCommand(command)
// 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 strings.HasSuffix(event.Name, "generated.go") {
continue
}
if event.Has(fsnotify.Write) {
success := runCommand(command)
if success {
log.Println(fmt.Sprintf("file changed. code generation successful"))
} else {
log.Println(fmt.Sprintf("file changed. code generation failed"))
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("error:", err)
}
}
}()
cwd, _ := os.Getwd()
pagesDir := filepath.Join(cwd, "pages")
partialsDir := filepath.Join(cwd, "partials")
toWatch := []string{pagesDir, partialsDir}
for _, watch := range toWatch {
err = watcher.Add(watch)
if err != nil {
panic(err)
}
}
// Block main goroutine forever.
<-make(chan struct{})
}
func runCommand(command string) bool {
// Create a new command
cmd := exec.Command("bash", "-c", command)
// Capture stdout and stderr in buffers
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
// Run the command
err := cmd.Run()
if err != nil {
log.Println(fmt.Sprintf("error: %v", err))
println(stderr.String())
return false
} else {
println(out.String())
return true
}
}

47
ui/button.go Normal file
View file

@ -0,0 +1,47 @@
package ui
import (
"mhtml/h"
)
type ButtonProps struct {
Text string
Target string
Get string
Class string
Children *h.Node
}
func PrimaryButton(props ButtonProps) *h.Node {
props.Class = h.MergeClasses(props.Class, "border-blue-700 bg-blue-700 text-white")
return Button(props)
}
func SecondaryButton(props ButtonProps) *h.Node {
props.Class = h.MergeClasses(props.Class, "border-gray-700 bg-gray-700 text-white")
return Button(props)
}
func Button(props ButtonProps) *h.Node {
text := h.P(props.Text)
button := h.Button(
h.If(props.Children != nil, props.Children),
h.Class("flex gap-1 items-center border p-4 rounded cursor-hover", props.Class),
h.If(props.Get != "", h.Get(props.Get)),
h.If(props.Target != "", h.Target(props.Target)),
//h.BeforeRequestSetHtml(
// h.Div(
// h.Class("flex gap-1"),
// h.Text("Loading..."),
// ),
//),
//h.AfterRequestSetHtml(h.Text(props.Text)),
// Note, i really like this idea of being able to reference elements just by the instance,
//and automatically adding id
text,
)
return button
}