From 35ee5959b317fae67d6f0a1f96d061735f9555e3 Mon Sep 17 00:00:00 2001 From: maddalax Date: Tue, 10 Sep 2024 19:52:18 -0500 Subject: [PATCH] lots of routign stuff --- .air.toml | 51 +++ .idea/dataSources.local.xml | 2 +- .../6b63d5bd-e451-4904-b659-21db5c54c16d.xml | 20 +- database/database.go | 45 +++ go.mod | 30 +- go.sum | 100 +---- h/app.go | 71 ++++ h/base.go | 59 +++ h/livereload.go | 33 ++ h/render.go | 35 ++ h/state.go | 35 ++ h/tag.go | 168 +++++++- httpjson/http.go | 15 +- index.html | 24 -- js/mhtml.js | 10 + justfile | 9 + main.go | 95 ++--- news/views.go | 15 +- pages/base/root.go | 22 ++ pages/generated.go | 17 + pages/index.go | 20 + pages/news.$id.go | 14 + pages/news.index.go | 42 ++ partials/base.go | 1 + partials/generated.go | 16 + partials/sheet.go | 56 +++ tooling/astgen/ast.go | 135 +++++++ tooling/astgen/codebuilder.go | 365 ++++++++++++++++++ tooling/astgen/entry.go | 268 +++++++++++++ tooling/astgen/map.go | 82 ++++ tooling/astgen/util.go | 22 ++ tooling/astgen/writer.go | 72 ++++ tooling/watch.go | 117 ++++++ ui/button.go | 47 +++ 34 files changed, 1891 insertions(+), 222 deletions(-) create mode 100644 .air.toml create mode 100644 h/app.go create mode 100644 h/base.go create mode 100644 h/livereload.go create mode 100644 h/state.go delete mode 100644 index.html create mode 100644 js/mhtml.js create mode 100644 justfile create mode 100644 pages/base/root.go create mode 100644 pages/generated.go create mode 100644 pages/index.go create mode 100644 pages/news.$id.go create mode 100644 pages/news.index.go create mode 100644 partials/base.go create mode 100644 partials/generated.go create mode 100644 partials/sheet.go create mode 100644 tooling/astgen/ast.go create mode 100644 tooling/astgen/codebuilder.go create mode 100644 tooling/astgen/entry.go create mode 100644 tooling/astgen/map.go create mode 100644 tooling/astgen/util.go create mode 100644 tooling/astgen/writer.go create mode 100644 tooling/watch.go create mode 100644 ui/button.go diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..58fff2a --- /dev/null +++ b/.air.toml @@ -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 diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml index 61a332e..5ccd4f5 100644 --- a/.idea/dataSources.local.xml +++ b/.idea/dataSources.local.xml @@ -1,6 +1,6 @@ - + " diff --git a/.idea/dataSources/6b63d5bd-e451-4904-b659-21db5c54c16d.xml b/.idea/dataSources/6b63d5bd-e451-4904-b659-21db5c54c16d.xml index 74f2361..082f5cd 100644 --- a/.idea/dataSources/6b63d5bd-e451-4904-b659-21db5c54c16d.xml +++ b/.idea/dataSources/6b63d5bd-e451-4904-b659-21db5c54c16d.xml @@ -1,9 +1,7 @@ - - - 3.40.1 - + + @@ -1428,34 +1426,34 @@ - TEXT|0s 1 + TEXT|0s - TEXT|0s 2 + TEXT|0s - TEXT|0s 3 + TEXT|0s - INT|0s 4 + INT|0s - TEXT|0s 5 + TEXT|0s - text|0s 1 1 + text|0s - text|0s 1 2 + text|0s id diff --git a/database/database.go b/database/database.go index 11750a0..0378399 100644 --- a/database/database.go +++ b/database/database.go @@ -35,6 +35,12 @@ func Connect() *redis.Client { 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) @@ -55,6 +61,45 @@ func HSet[T any](set string, key string, value T) error { 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() diff --git a/go.mod b/go.mod index c715135..16d020d 100644 --- a/go.mod +++ b/go.mod @@ -3,26 +3,24 @@ module mhtml go 1.20 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gofiber/fiber/v2 v2.47.0 // indirect - github.com/google/uuid v1.3.0 // indirect - github.com/klauspost/compress v1.16.3 // indirect + github.com/klauspost/compress v1.17.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mattn/go-runewidth v0.0.14 // 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/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // 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/fasthttp v1.47.0 // indirect + github.com/valyala/fasthttp v1.52.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.9.0 // indirect + golang.org/x/sys v0.18.0 // indirect ) diff --git a/go.sum b/go.sum index ec143fa..e7cff45 100644 --- a/go.sum +++ b/go.sum @@ -1,103 +1,39 @@ -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +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/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/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/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs= -github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= -github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +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/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM= +github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +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/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/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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/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.14/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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 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/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c= -github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0= +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/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.3.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.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -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/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4= 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= diff --git a/h/app.go b/h/app.go new file mode 100644 index 0000000..c312a22 --- /dev/null +++ b/h/app.go @@ -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, + ), + ) +} diff --git a/h/base.go b/h/base.go new file mode 100644 index 0000000..b5fcc57 --- /dev/null +++ b/h/base.go @@ -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 +} diff --git a/h/livereload.go b/h/livereload.go new file mode 100644 index 0000000..26fa5ff --- /dev/null +++ b/h/livereload.go @@ -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) +} diff --git a/h/render.go b/h/render.go index 26e2e3a..d8ff3d9 100644 --- a/h/render.go +++ b/h/render.go @@ -3,9 +3,13 @@ package h import ( "fmt" "strings" + "time" ) const FlagSkip = "skip" +const FlagText = "text" +const FlagRaw = "raw" +const FlagAttributeList = "attribute-list" type Builder struct { builder *strings.Builder @@ -35,11 +39,26 @@ func (page Builder) renderNode(node *Node) { } for _, child := range node.children { + + if child == nil { + continue + } + if child.tag == "class" { insertAttribute(node, "class", child.value) 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" { for key, value := range child.attributes { insertAttribute(node, key, value) @@ -68,6 +87,19 @@ func (page Builder) renderNode(node *Node) { } } 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 { page.renderNode(child) } @@ -78,6 +110,7 @@ func (page Builder) renderNode(node *Node) { } func Render(node *Node) string { + start := time.Now() builder := strings.Builder{} page := Builder{ builder: &builder, @@ -85,5 +118,7 @@ func Render(node *Node) string { } page.render() d := page.builder.String() + duration := time.Since(start) + fmt.Printf("render took %s\n", duration) return d } diff --git a/h/state.go b/h/state.go new file mode 100644 index 0000000..6059b7b --- /dev/null +++ b/h/state.go @@ -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") +} diff --git a/h/tag.go b/h/tag.go index dd3a62f..79bacc4 100644 --- a/h/tag.go +++ b/h/tag.go @@ -1,20 +1,80 @@ package h +import ( + "encoding/json" + "github.com/gofiber/fiber/v2" + "html" + "net/url" + "strings" +) + type Node struct { + id string tag string attributes map[string]string children []*Node text 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{ 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 { return Attribute("id", value) } @@ -28,8 +88,43 @@ func Attribute(key string, value string) *Node { } } -func Get(url string) *Node { - return Attribute("hx-get", url) +func Get(path string) *Node { + 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 { @@ -40,6 +135,13 @@ func Trigger(trigger string) *Node { return Attribute("hx-trigger", trigger) } +func Text(text string) *Node { + return &Node{ + tag: "text", + text: text, + } +} + func Target(target string) *Node { return Attribute("hx-target", target) } @@ -72,7 +174,11 @@ func Click(value string) *Node { 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{ tag: "html", 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("") +} + func Div(children ...*Node) *Node { return &Node{ 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 { return &Node{ 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 { return &Node{ 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 { if condition { 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 { if condition { return node diff --git a/httpjson/http.go b/httpjson/http.go index f45457b..c86cff9 100644 --- a/httpjson/http.go +++ b/httpjson/http.go @@ -2,6 +2,8 @@ package httpjson import ( "encoding/json" + "errors" + "fmt" "io" "net/http" "sync" @@ -21,10 +23,6 @@ func getClient() *http.Client { } httpClient := &http.Client{ Transport: tr, - // do not follow redirects - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, } client = httpClient }) @@ -43,6 +41,10 @@ func Get[T any](url string) (T, error) { 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) if err != nil { return *new(T), err @@ -52,5 +54,10 @@ func Get[T any](url string) (T, error) { if err != nil { return *new(T), err } + + if d == nil { + return *new(T), errors.New("failed to create T") + } + return *d, nil } diff --git a/index.html b/index.html deleted file mode 100644 index 24e48f8..0000000 --- a/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - -
-
-

Log in - -

-

Sydne Anschutz

-

sanschutz0808@gmail.com

- -
-

Maddy

-

jm@madev.me

- -
-
-

Enter name

-
- - \ No newline at end of file diff --git a/js/mhtml.js b/js/mhtml.js new file mode 100644 index 0000000..ae03ebc --- /dev/null +++ b/js/mhtml.js @@ -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() + }) + }); +} \ No newline at end of file diff --git a/justfile b/justfile new file mode 100644 index 0000000..6e85aec --- /dev/null +++ b/justfile @@ -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' \ No newline at end of file diff --git a/main.go b/main.go index c87b2cc..f6ffe60 100644 --- a/main.go +++ b/main.go @@ -2,85 +2,36 @@ package main import ( "github.com/gofiber/fiber/v2" - "mhtml/database" + "github.com/google/uuid" "mhtml/h" - "mhtml/news" - "strconv" - "time" + "mhtml/pages" + "mhtml/partials" ) -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() { + f := fiber.New() + f.Static("/js", "./js") - database.HSet("users", "sydne", User{ - Name: "Sydne Anschutz", - Email: "sanschutz0808@gmail.com", + f.Use(func(ctx *fiber.Ctx) error { + if ctx.Cookies("mhtml-session") != "" { + 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() - - app.Get("/", IndexPage) - app.Get("/news/:id", func(c *fiber.Ctx) error { - return HtmlView(c, Page( - news.StoryFull(c.Params("id")), - )) + f.Get("/mhtml/partials.*", func(ctx *fiber.Ctx) error { + return h.PartialView(ctx, partials.GetPartialFromContext(ctx)) }) - app.Get("/livereload", LiveReloadHandler) - app.Listen(":3000") + pages.RegisterPages(f) + + h.Start(f, h.App{ + LiveReload: true, + }) } diff --git a/news/views.go b/news/views.go index 46a56d1..d91942d 100644 --- a/news/views.go +++ b/news/views.go @@ -2,22 +2,23 @@ package news import ( "fmt" + "mhtml/database" "mhtml/h" ) func StoryList() *h.Node { - posts, err := List() - if err != nil { - return h.P(err.Error()) - } + posts, _ := database.GetOrSet[[]Post]("posts", func() []Post { + p, _ := List() + return p + }) - if len(posts) == 0 { + if len(*posts) == 0 { return h.P("No results found") } 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) })), ) @@ -26,7 +27,7 @@ func StoryList() *h.Node { func StoryCard(post Post) *h.Node { url := fmt.Sprintf("/news/%d", post.Id) 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)), ) } diff --git a/pages/base/root.go b/pages/base/root.go new file mode 100644 index 0000000..03d7880 --- /dev/null +++ b/pages/base/root.go @@ -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...), + ), + ), + ) +} diff --git a/pages/generated.go b/pages/generated.go new file mode 100644 index 0000000..06b072d --- /dev/null +++ b/pages/generated.go @@ -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)) + }) +} diff --git a/pages/index.go b/pages/index.go new file mode 100644 index 0000000..ee7b032 --- /dev/null +++ b/pages/index.go @@ -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"), + ), + )), + )) +} diff --git a/pages/news.$id.go b/pages/news.$id.go new file mode 100644 index 0000000..650716c --- /dev/null +++ b/pages/news.$id.go @@ -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)), + ) +} diff --git a/pages/news.index.go b/pages/news.index.go new file mode 100644 index 0000000..eae9ada --- /dev/null +++ b/pages/news.index.go @@ -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"), + }), + ) +} diff --git a/partials/base.go b/partials/base.go new file mode 100644 index 0000000..4feeaa0 --- /dev/null +++ b/partials/base.go @@ -0,0 +1 @@ +package partials diff --git a/partials/generated.go b/partials/generated.go new file mode 100644 index 0000000..f273aee --- /dev/null +++ b/partials/generated.go @@ -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 +} diff --git a/partials/sheet.go b/partials/sheet.go new file mode 100644 index 0000000..8e8162d --- /dev/null +++ b/partials/sheet.go @@ -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(), + )) +} diff --git a/tooling/astgen/ast.go b/tooling/astgen/ast.go new file mode 100644 index 0000000..7c77a80 --- /dev/null +++ b/tooling/astgen/ast.go @@ -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() +} diff --git a/tooling/astgen/codebuilder.go b/tooling/astgen/codebuilder.go new file mode 100644 index 0000000..3743144 --- /dev/null +++ b/tooling/astgen/codebuilder.go @@ -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 +} diff --git a/tooling/astgen/entry.go b/tooling/astgen/entry.go new file mode 100644 index 0000000..ac3fac8 --- /dev/null +++ b/tooling/astgen/entry.go @@ -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() +} diff --git a/tooling/astgen/map.go b/tooling/astgen/map.go new file mode 100644 index 0000000..e212093 --- /dev/null +++ b/tooling/astgen/map.go @@ -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 + } + } + } +} diff --git a/tooling/astgen/util.go b/tooling/astgen/util.go new file mode 100644 index 0000000..204d389 --- /dev/null +++ b/tooling/astgen/util.go @@ -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 +} diff --git a/tooling/astgen/writer.go b/tooling/astgen/writer.go new file mode 100644 index 0000000..b20e076 --- /dev/null +++ b/tooling/astgen/writer.go @@ -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) + } +} diff --git a/tooling/watch.go b/tooling/watch.go new file mode 100644 index 0000000..5712934 --- /dev/null +++ b/tooling/watch.go @@ -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 + } +} diff --git a/ui/button.go b/ui/button.go new file mode 100644 index 0000000..80435a4 --- /dev/null +++ b/ui/button.go @@ -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 +}