Mirgate project to go
20
.gitignore
vendored
@@ -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/
|
||||
|
||||
|
||||
66
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`.
|
||||
|
||||
50
cmd/luxtools/main.go
Normal 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")
|
||||
}
|
||||
}
|
||||
170
internal/server/server.go
Normal 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
|
||||
}
|
||||
68
internal/server/server_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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 "
|
||||
178
src/luxtools.nim
@@ -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()
|
||||
@@ -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)
|
||||
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 166 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 166 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 166 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 167 B After Width: | Height: | Size: 167 B |
|
Before Width: | Height: | Size: 167 B After Width: | Height: | Size: 167 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 167 B After Width: | Height: | Size: 167 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 167 B After Width: | Height: | Size: 167 B |