Mirgate project to go

This commit is contained in:
2025-10-02 13:23:09 +02:00
parent d0c2a48238
commit 0e54a6019e
40 changed files with 325 additions and 272 deletions

20
.gitignore vendored
View File

@@ -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/

View File

@@ -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`.

50
cmd/luxtools/main.go Normal file
View File

@@ -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")
}
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module luxtools
go 1.22.0

170
internal/server/server.go Normal file
View File

@@ -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 = `
<div class="tui-window">
<fieldset class="tui-fieldset">
<legend>Error</legend>
<p>Route %s not found.</p>
</fieldset>
</div>
`
)
// 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(`
<div id="counter" class="tui-panel tui-panel-inline">
<p><strong>Clicks:</strong> %d</p>
<button class="tui-button" hx-post="/counter" hx-target="#counter" hx-swap="outerHTML">
Increment
</button>
</div>
`, count)
}
func renderTime(now time.Time) string {
return fmt.Sprintf(`
<div id="server-time" class="tui-panel tui-panel-inline">
<p><strong>Server time:</strong> %s</p>
</div>
`, now.Format("2006-01-02 15:04:05"))
}
func allowGetHead(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead:
next.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
func goProjectRoot() (string, error) {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
return "", errors.New("unable to determine caller information")
}
dir := filepath.Dir(currentFile)
projectRoot := filepath.Clean(filepath.Join(dir, "..", ".."))
return projectRoot, nil
}

View File

@@ -0,0 +1,68 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestRenderCounter(t *testing.T) {
html := renderCounter(7)
if !strings.Contains(html, "Clicks:") {
t.Fatalf("expected counter markup to include label")
}
if !strings.Contains(html, "> 7<") {
t.Fatalf("expected counter value in markup, got %q", html)
}
}
func TestRenderTime(t *testing.T) {
now := time.Date(2024, time.August, 1, 12, 34, 56, 0, time.UTC)
html := renderTime(now)
expected := "2024-08-01 12:34:56"
if !strings.Contains(html, expected) {
t.Fatalf("expected formatted timestamp %s in markup", expected)
}
}
func TestCounterEndpoint(t *testing.T) {
srv, err := New()
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/counter", nil)
resp := httptest.NewRecorder()
srv.Router().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", resp.Code)
}
body := resp.Body.String()
if !strings.Contains(body, "> 1<") {
t.Fatalf("expected counter to increment to 1, body: %q", body)
}
}
func TestNotFound(t *testing.T) {
srv, err := New()
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/unknown", nil)
resp := httptest.NewRecorder()
srv.Router().ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.Code)
}
if !strings.Contains(resp.Body.String(), "Route /unknown not found.") {
t.Fatalf("expected not found message, got %q", resp.Body.String())
}
}

View File

@@ -1,18 +0,0 @@
# Nimble package definition for the Luxtools demo web application
version = "0.1.0"
author = "Luxtools Contributors"
description = "Scaffolding for a Nim + HTMX + TUI.CSS single-binary web application"
license = "MIT"
srcDir = "src"
bin = @["luxtools"]
binDir = "bin"
requires "nim >= 1.6.0"
task build, "Build the optimized standalone binary":
exec "nim c -d:release --opt:speed src/luxtools.nimsrc/luxtools.nim"
task test, "Execute the lightweight test suite":
exec "nim c --path:src -o:bin/test_rendering -r tests/test_rendering.nim "

View File

@@ -1,178 +0,0 @@
import std/[asynchttpserver, asyncdispatch, os, strformat, strutils, tables, times]
const
indexPage = staticRead("templates/index.html")
type
StaticFile = tuple[content: string, contentType: string]
const
defaultContentType = "application/octet-stream"
contentTypeByExt = block:
var table = initTable[string, string]()
table[".js"] = "application/javascript"
table[".css"] = "text/css"
table[".html"] = "text/html; charset=utf-8"
table[".json"] = "application/json"
table[".png"] = "image/png"
table[".jpg"] = "image/jpeg"
table[".jpeg"] = "image/jpeg"
table[".svg"] = "image/svg+xml"
table[".gif"] = "image/gif"
table[".ico"] = "image/x-icon"
table[".ttf"] = "font/ttf"
table[".woff"] = "font/woff"
table[".woff2"] = "font/woff2"
table[".txt"] = "text/plain; charset=utf-8"
table
proc guessContentType(path: string): string =
let ext = splitFile(path).ext.toLowerAscii()
if contentTypeByExt.hasKey(ext):
contentTypeByExt[ext]
else:
defaultContentType
const staticRootDir = parentDir(currentSourcePath()) / "static"
const staticFileEntries: seq[(string, StaticFile)] = block:
var entries: seq[(string, StaticFile)] = @[]
for path in walkDirRec(staticRootDir):
let fullPath = if isAbsolute(path): path else: staticRootDir / path
if not fileExists(fullPath):
continue
let relPath = relativePath(fullPath, staticRootDir)
if relPath.len == 0 or relPath.startsWith(".."):
continue
let normalizedRel = relPath.replace(DirSep, '/')
let urlPath = "/static/" & normalizedRel
let content = staticRead(fullPath)
let contentType = guessContentType(fullPath)
entries.add((urlPath, (content: content, contentType: contentType)))
entries
proc findStaticFile(path: string; asset: var StaticFile): bool =
for entry in staticFileEntries:
if entry[0] == path:
asset = entry[1]
return true
false
proc assetsRoot(): string =
absolutePath(joinPath(getAppDir(), "..", "assets"))
var clickCount = 0
proc htmlHeaders(): HttpHeaders =
result = newHttpHeaders()
result.add("Content-Type", "text/html; charset=utf-8")
proc assetHeaders(contentType: string): HttpHeaders =
result = newHttpHeaders()
result.add("Content-Type", contentType)
proc renderCounter*(count: int): string =
&"""
<div id="counter" class="tui-panel tui-panel-inline">
<p><strong>Clicks:</strong> {count}</p>
<button class="tui-button" hx-post="/counter" hx-target="#counter" hx-swap="outerHTML">
Increment
</button>
</div>
"""
proc renderTime(): string =
let now = now().format("yyyy-MM-dd HH:mm:ss")
&"""
<div id="server-time" class="tui-panel tui-panel-inline">
<p><strong>Server time:</strong> {now}</p>
</div>
"""
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, """
<div class="tui-window">
<fieldset class="tui-fieldset">
<legend>Error</legend>
<p>Route """ & req.url.path & """ not found.</p>
</fieldset>
</div>
""")
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()

View File

@@ -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("<script src=\"/static/lib/htmx.2.0.7.min.js\" defer></script>")
check indexTemplate.contains("/static/lib/tuicss/tuicss.min.css")
check indexTemplate.contains("href=\"/static/lib/tuicss/tuicss.min.css\"")
check indexTemplate.contains("<script src=\"/static/lib/tuicss/tuicss.min.js\" defer></script>")
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)

View File

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 166 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 165 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 165 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 165 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 165 B

View File

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 166 B

View File

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 165 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 165 B

View File

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 166 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 167 B

View File

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 167 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 167 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 167 B