diff --git a/.gitignore b/.gitignore
index 7d9d44f..5033616 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,21 +1,10 @@
-# Nim compiler cache
-nimcache/
-nimcache_*/
-nimcache.*
-
-# Nimble package manager cache and build metadata
-nimblecache/
-nimbledeps/
-*.nimbledir/
-*.nimblecache
-*.nims.lock
-*.nimsbak
-
# Build outputs
bin/
build/
-htmldocs/
-jsondocs/
+
+# Go tool artifacts
+*.test
+coverage.out
# Compiled binaries and objects
*.exe
@@ -44,5 +33,4 @@ jsondocs/
Thumbs.db
.idea/
.vscode/
-project.nimcache/
diff --git a/README.md b/README.md
index 71c49c6..3d85223 100755
--- a/README.md
+++ b/README.md
@@ -1,56 +1,50 @@
-# Luxtools Web Application Scaffold
+# Luxtools (Go edition)
-Luxtools is a minimal-but-complete scaffold for a retro-styled web application built with the Nim programming language. It demonstrates how to deliver an interactive HTMX front end backed by a Nim HTTP server that compiles down to a single distributable binary.
-
-## Why these choices?
-
-- **Nim**: Nim's ahead-of-time compilation and small standard library make it ideal for producing a single self-contained binary. Its async HTTP primitives provide great performance with minimal boilerplate.
-- **HTMX**: HTMX keeps the frontend simple by embracing hypermedia. We can ship almost entirely server-rendered HTML snippets, allowing Nim to stay in control without a bulky SPA build step.
-- **TUI.CSS**: This lightweight CSS framework offers a retro terminal aesthetic with zero JavaScript and a tiny footprint—perfect for a nostalgic interface while remaining easy to customize.
-- **Single binary delivery**: HTML templates and the entire `src/static/` tree of vendored assets are embedded at compile time via `staticRead`, so the server ships with everything it needs. The compiled binary serves those resources directly from an in-memory table, while a separate `assets/` directory remains available for user-provided uploads.
+Luxtools is now implemented entirely in Go. The project serves a retro-styled HTMX interface backed by a lightweight Go HTTP server that mirrors the behaviour of the former Nim version.
## Project layout
```
luxtools/
-├── bin/ # Output directory for release binaries
-├── src/
-│ ├── luxtools.nim # Main server entrypoint
-│ ├── static/ # Vendored assets compiled into the binary static table
-│ └── templates/ # HTMX-powered landing page (compiled into the binary)
-├── assets/ # Runtime asset directory served from disk
-├── tests/
-│ └── test_rendering.nim# Lightweight regression tests for HTML helpers
-├── luxtools.nimble # Nimble manifest with build/test tasks
+├── assets/ # Runtime asset directory served from disk (/assets/*)
+├── cmd/luxtools/ # Main program entrypoint
+├── internal/server/ # HTTP server, handlers, helpers, and tests
+├── web/
+│ ├── static/ # Vendored front-end assets (HTMX, TUI.CSS, images)
+│ └── templates/ # HTML templates rendered by the server
+├── deploy/ # Deployment manifests (systemd unit, etc.)
+├── go.mod # Go module definition
└── README.md # You're here
```
-## Running the app
+## Prerequisites
-```text
-nimble run
+- Go 1.22 or newer
+
+## Run the server
+
+```bash
+go run ./cmd/luxtools
```
-This command compiles the server (in debug mode) and starts it at http://127.0.0.1:5000. The console output confirms the address. Open the URL in a browser to see the TUI.CSS interface. The counter widget uses `hx-post` to mutate state on the server, and the clock panel uses `hx-get` with a timed trigger.
+The server listens on [http://127.0.0.1:5000](http://127.0.0.1:5000) and serves:
-## Building the single binary
-
-```text
-nimble build
-```
-
-A release-optimized executable is emitted to `bin/luxtools` (or `bin/luxtools.exe` on Windows). Because the HTML is embedded at compile time, shipping this one file is enough.
+- `/` — main page rendered from `web/templates/index.html`
+- `/counter` — HTMX-powered counter snippet with server-side state
+- `/time` — current server timestamp
+- `/static/*` — vendored assets from `web/static`
+- `/assets/*` — runtime assets from the shared `assets` directory
## Testing
-```text
-nimble test
+```bash
+go test ./...
```
-The test suite checks the generated HTMX markup to catch regressions in the HTML helpers without spinning up the HTTP server.
+## Building for deployment
-## Next steps (optional)
+```bash
+go build -o bin/luxtools ./cmd/luxtools
+```
-- Add persistence by wiring Nim's database libraries or a lightweight KV store.
-- Serve additional HTMX endpoints (e.g., todo lists, metrics dashboards).
-- Extend the styling by composing more TUI.CSS components or creating custom themes.
+The resulting binary is compatible with the provided systemd unit located in `deploy/luxtools.service`.
diff --git a/cmd/luxtools/main.go b/cmd/luxtools/main.go
new file mode 100644
index 0000000..2141df8
--- /dev/null
+++ b/cmd/luxtools/main.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "luxtools/internal/server"
+)
+
+func main() {
+ srv, err := server.New()
+ if err != nil {
+ log.Fatalf("failed to configure server: %v", err)
+ }
+
+ httpServer := &http.Server{
+ Addr: srv.Address(),
+ Handler: srv.Router(),
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ log.Printf("Luxtools web server running on http://127.0.0.1%s", srv.Address())
+
+ go func() {
+ if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatalf("server error: %v", err)
+ }
+ }()
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err := httpServer.Shutdown(shutdownCtx); err != nil {
+ log.Printf("graceful shutdown failed: %v", err)
+ } else {
+ log.Println("server stopped")
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..339ad76
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module luxtools
+
+go 1.22.0
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..1fd4c78
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,170 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "html"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "time"
+)
+
+const (
+ address = ":5000"
+ htmlContentType = "text/html; charset=utf-8"
+ notFoundTemplate = `
+
+
+
+ `
+)
+
+// Server hosts the Luxtools HTTP handlers.
+type Server struct {
+ mux *http.ServeMux
+ indexPage []byte
+ staticRoot string
+ assetsRoot string
+
+ counterMu sync.Mutex
+ clickCount int
+}
+
+// New constructs a configured Server ready to serve requests.
+func New() (*Server, error) {
+ projectRoot, err := goProjectRoot()
+ if err != nil {
+ return nil, err
+ }
+
+ indexPath := filepath.Join(projectRoot, "web", "templates", "index.html")
+ staticDir := filepath.Join(projectRoot, "web", "static")
+ assetsDir := filepath.Join(projectRoot, "assets")
+
+ indexPage, err := os.ReadFile(indexPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read index template: %w", err)
+ }
+
+ if _, err := os.Stat(staticDir); err != nil {
+ return nil, fmt.Errorf("static directory check failed: %w", err)
+ }
+ if _, err := os.Stat(assetsDir); err != nil {
+ return nil, fmt.Errorf("assets directory check failed: %w", err)
+ }
+
+ s := &Server{
+ mux: http.NewServeMux(),
+ indexPage: indexPage,
+ staticRoot: staticDir,
+ assetsRoot: assetsDir,
+ }
+
+ s.registerRoutes()
+ return s, nil
+}
+
+// Router exposes the configured http.Handler for the server.
+func (s *Server) Router() http.Handler {
+ return s.mux
+}
+
+// Address returns the default listen address for the server.
+func (s *Server) Address() string {
+ return address
+}
+
+func (s *Server) registerRoutes() {
+ s.mux.Handle("/static/", http.StripPrefix("/static/", allowGetHead(http.FileServer(http.Dir(s.staticRoot)))))
+ s.mux.Handle("/assets/", http.StripPrefix("/assets/", allowGetHead(http.FileServer(http.Dir(s.assetsRoot)))))
+ s.mux.HandleFunc("/", s.handleRequest)
+}
+
+func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/":
+ s.sendHTML(w, http.StatusOK, s.indexPage)
+ case "/counter":
+ s.handleCounter(w, r)
+ case "/time":
+ s.sendHTMLString(w, http.StatusOK, renderTime(time.Now()))
+ default:
+ s.sendHTMLString(w, http.StatusNotFound, fmt.Sprintf(notFoundTemplate, html.EscapeString(r.URL.Path)))
+ }
+}
+
+func (s *Server) handleCounter(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost || r.Method == http.MethodPut {
+ s.counterMu.Lock()
+ s.clickCount++
+ s.counterMu.Unlock()
+ }
+
+ s.counterMu.Lock()
+ count := s.clickCount
+ s.counterMu.Unlock()
+
+ s.sendHTMLString(w, http.StatusOK, renderCounter(count))
+}
+
+func (s *Server) sendHTML(w http.ResponseWriter, status int, body []byte) {
+ w.Header().Set("Content-Type", htmlContentType)
+ w.WriteHeader(status)
+ if len(body) > 0 {
+ if _, err := w.Write(body); err != nil {
+ log.Printf("write response failed: %v", err)
+ }
+ }
+}
+
+func (s *Server) sendHTMLString(w http.ResponseWriter, status int, body string) {
+ s.sendHTML(w, status, []byte(body))
+}
+
+func renderCounter(count int) string {
+ return fmt.Sprintf(`
+
- """
-
-proc renderTime(): string =
- let now = now().format("yyyy-MM-dd HH:mm:ss")
- &"""
-
-
Server time: {now}
-
- """
-
-proc sendHtml(req: Request, body: string) {.async, gcsafe.} =
- await req.respond(Http200, body, htmlHeaders())
-
-proc sendHtml(req: Request, code: HttpCode, body: string) {.async, gcsafe.} =
- await req.respond(code, body, htmlHeaders())
-
-proc sendAsset(req: Request, body: string, contentType: string) {.async, gcsafe.} =
- await req.respond(Http200, body, assetHeaders(contentType))
-
-proc trySendStatic(req: Request): Future[bool] {.async, gcsafe.} =
- if req.reqMethod notin {HttpGet, HttpHead}:
- return false
-
- let path = req.url.path
- var asset: StaticFile
- if not findStaticFile(path, asset):
- return false
- if req.reqMethod == HttpHead:
- await req.respond(Http200, "", assetHeaders(asset.contentType))
- else:
- await sendAsset(req, asset.content, asset.contentType)
- return true
-
-proc trySendFileAsset(req: Request): Future[bool] {.async, gcsafe.} =
- const assetPrefix = "/assets/"
-
- if req.reqMethod notin {HttpGet, HttpHead}:
- return false
-
- let path = req.url.path
- if not path.startsWith(assetPrefix):
- return false
-
- if path.len == assetPrefix.len:
- return false
-
- let relative = path[assetPrefix.len .. ^1]
- if relative.len == 0 or relative.contains("..") or relative.contains('\\'):
- return false
-
- let root = assetsRoot()
- if not dirExists(root):
- return false
-
- let assetPath = joinPath(root, relative)
- if not fileExists(assetPath):
- return false
-
- let contentType = guessContentType(assetPath)
- if req.reqMethod == HttpHead:
- await req.respond(Http200, "", assetHeaders(contentType))
- else:
- await sendAsset(req, readFile(assetPath), contentType)
- return true
-
-proc handleRequest(req: Request) {.async, gcsafe.} =
- case req.url.path
- of "/":
- await sendHtml(req, indexPage)
- of "/counter":
- if req.reqMethod in {HttpPost, HttpPut}:
- inc clickCount
- await sendHtml(req, renderCounter(clickCount))
- of "/time":
- await sendHtml(req, renderTime())
- else:
- if await trySendStatic(req):
- return
- if await trySendFileAsset(req):
- return
- await sendHtml(req, Http404, """
-
-
-
- """)
-
-proc runServer() {.async.} =
- let server = newAsyncHttpServer()
- let port = Port(5000)
- echo &"Luxtools web server running on http://127.0.0.1:{port.int}"
- await server.serve(port, handleRequest)
-
-when isMainModule:
- waitFor runServer()
diff --git a/tests/test_rendering.nim b/tests/test_rendering.nim
deleted file mode 100755
index f5519c5..0000000
--- a/tests/test_rendering.nim
+++ /dev/null
@@ -1,24 +0,0 @@
-import std/[os, strutils, unittest]
-import ../src/luxtools
-
-const indexTemplate = staticRead("../src/templates/index.html")
-
-suite "rendering helpers":
- test "counter markup references hx attributes":
- let html = renderCounter(3)
- check html.contains("hx-post=\"/counter\"")
- check html.contains("id=\"counter\"")
- check html.contains("3")
-
- test "index template uses embedded htmx build":
- check indexTemplate.contains("/static/lib/htmx.2.0.7.min.js")
- check indexTemplate.contains("")
- check indexTemplate.contains("/static/lib/tuicss/tuicss.min.css")
- check indexTemplate.contains("href=\"/static/lib/tuicss/tuicss.min.css\"")
- check indexTemplate.contains("")
- check not indexTemplate.contains("unpkg.com/htmx")
- check not indexTemplate.contains("unpkg.com/tui-css")
-
- test "demo asset lives in assets directory":
- let assetPath = joinPath(getAppDir(), "..", "assets", "demo.txt")
- check fileExists(assetPath)
diff --git a/src/static/lib/htmx.2.0.7.min.js b/web/static/lib/htmx.2.0.7.min.js
similarity index 100%
rename from src/static/lib/htmx.2.0.7.min.js
rename to web/static/lib/htmx.2.0.7.min.js
diff --git a/src/static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf b/web/static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf
similarity index 100%
rename from src/static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf
rename to web/static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf
diff --git a/src/static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf b/web/static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf
similarity index 100%
rename from src/static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf
rename to web/static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf
diff --git a/src/static/lib/tuicss/fonts/dos437.txt b/web/static/lib/tuicss/fonts/dos437.txt
similarity index 100%
rename from src/static/lib/tuicss/fonts/dos437.txt
rename to web/static/lib/tuicss/fonts/dos437.txt
diff --git a/src/static/lib/tuicss/images/bg-blue-black.png b/web/static/lib/tuicss/images/bg-blue-black.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-blue-black.png
rename to web/static/lib/tuicss/images/bg-blue-black.png
diff --git a/src/static/lib/tuicss/images/bg-blue-white.png b/web/static/lib/tuicss/images/bg-blue-white.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-blue-white.png
rename to web/static/lib/tuicss/images/bg-blue-white.png
diff --git a/src/static/lib/tuicss/images/bg-cyan-black.png b/web/static/lib/tuicss/images/bg-cyan-black.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-cyan-black.png
rename to web/static/lib/tuicss/images/bg-cyan-black.png
diff --git a/src/static/lib/tuicss/images/bg-cyan-white.png b/web/static/lib/tuicss/images/bg-cyan-white.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-cyan-white.png
rename to web/static/lib/tuicss/images/bg-cyan-white.png
diff --git a/src/static/lib/tuicss/images/bg-green-black.png b/web/static/lib/tuicss/images/bg-green-black.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-green-black.png
rename to web/static/lib/tuicss/images/bg-green-black.png
diff --git a/src/static/lib/tuicss/images/bg-green-white.png b/web/static/lib/tuicss/images/bg-green-white.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-green-white.png
rename to web/static/lib/tuicss/images/bg-green-white.png
diff --git a/src/static/lib/tuicss/images/bg-orange-black.png b/web/static/lib/tuicss/images/bg-orange-black.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-orange-black.png
rename to web/static/lib/tuicss/images/bg-orange-black.png
diff --git a/src/static/lib/tuicss/images/bg-orange-white.png b/web/static/lib/tuicss/images/bg-orange-white.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-orange-white.png
rename to web/static/lib/tuicss/images/bg-orange-white.png
diff --git a/src/static/lib/tuicss/images/bg-purple-black.png b/web/static/lib/tuicss/images/bg-purple-black.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-purple-black.png
rename to web/static/lib/tuicss/images/bg-purple-black.png
diff --git a/src/static/lib/tuicss/images/bg-purple-white.png b/web/static/lib/tuicss/images/bg-purple-white.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-purple-white.png
rename to web/static/lib/tuicss/images/bg-purple-white.png
diff --git a/src/static/lib/tuicss/images/bg-red-black.png b/web/static/lib/tuicss/images/bg-red-black.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-red-black.png
rename to web/static/lib/tuicss/images/bg-red-black.png
diff --git a/src/static/lib/tuicss/images/bg-red-white.png b/web/static/lib/tuicss/images/bg-red-white.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-red-white.png
rename to web/static/lib/tuicss/images/bg-red-white.png
diff --git a/src/static/lib/tuicss/images/bg-yellow-black.png b/web/static/lib/tuicss/images/bg-yellow-black.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-yellow-black.png
rename to web/static/lib/tuicss/images/bg-yellow-black.png
diff --git a/src/static/lib/tuicss/images/bg-yellow-white.png b/web/static/lib/tuicss/images/bg-yellow-white.png
similarity index 100%
rename from src/static/lib/tuicss/images/bg-yellow-white.png
rename to web/static/lib/tuicss/images/bg-yellow-white.png
diff --git a/src/static/lib/tuicss/images/scroll-blue.png b/web/static/lib/tuicss/images/scroll-blue.png
similarity index 100%
rename from src/static/lib/tuicss/images/scroll-blue.png
rename to web/static/lib/tuicss/images/scroll-blue.png
diff --git a/src/static/lib/tuicss/images/scroll-cyan.png b/web/static/lib/tuicss/images/scroll-cyan.png
similarity index 100%
rename from src/static/lib/tuicss/images/scroll-cyan.png
rename to web/static/lib/tuicss/images/scroll-cyan.png
diff --git a/src/static/lib/tuicss/images/scroll-green.png b/web/static/lib/tuicss/images/scroll-green.png
similarity index 100%
rename from src/static/lib/tuicss/images/scroll-green.png
rename to web/static/lib/tuicss/images/scroll-green.png
diff --git a/src/static/lib/tuicss/images/scroll-orange.png b/web/static/lib/tuicss/images/scroll-orange.png
similarity index 100%
rename from src/static/lib/tuicss/images/scroll-orange.png
rename to web/static/lib/tuicss/images/scroll-orange.png
diff --git a/src/static/lib/tuicss/images/scroll-purple.png b/web/static/lib/tuicss/images/scroll-purple.png
similarity index 100%
rename from src/static/lib/tuicss/images/scroll-purple.png
rename to web/static/lib/tuicss/images/scroll-purple.png
diff --git a/src/static/lib/tuicss/images/scroll-red.png b/web/static/lib/tuicss/images/scroll-red.png
similarity index 100%
rename from src/static/lib/tuicss/images/scroll-red.png
rename to web/static/lib/tuicss/images/scroll-red.png
diff --git a/src/static/lib/tuicss/images/scroll-white.png b/web/static/lib/tuicss/images/scroll-white.png
similarity index 100%
rename from src/static/lib/tuicss/images/scroll-white.png
rename to web/static/lib/tuicss/images/scroll-white.png
diff --git a/src/static/lib/tuicss/images/scroll-yellow.png b/web/static/lib/tuicss/images/scroll-yellow.png
similarity index 100%
rename from src/static/lib/tuicss/images/scroll-yellow.png
rename to web/static/lib/tuicss/images/scroll-yellow.png
diff --git a/src/static/lib/tuicss/tuicss.css b/web/static/lib/tuicss/tuicss.css
similarity index 100%
rename from src/static/lib/tuicss/tuicss.css
rename to web/static/lib/tuicss/tuicss.css
diff --git a/src/static/lib/tuicss/tuicss.js b/web/static/lib/tuicss/tuicss.js
similarity index 100%
rename from src/static/lib/tuicss/tuicss.js
rename to web/static/lib/tuicss/tuicss.js
diff --git a/src/static/lib/tuicss/tuicss.min.css b/web/static/lib/tuicss/tuicss.min.css
similarity index 100%
rename from src/static/lib/tuicss/tuicss.min.css
rename to web/static/lib/tuicss/tuicss.min.css
diff --git a/src/static/lib/tuicss/tuicss.min.js b/web/static/lib/tuicss/tuicss.min.js
similarity index 100%
rename from src/static/lib/tuicss/tuicss.min.js
rename to web/static/lib/tuicss/tuicss.min.js
diff --git a/src/templates/index.html b/web/templates/index.html
similarity index 100%
rename from src/templates/index.html
rename to web/templates/index.html