From 3a12f8f47d942dc5b4b44a8b8bba57b48271a750 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 19:25:43 +0200 Subject: [PATCH] feat(01-03): templ layout/index/fragments + handlers + chi router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - templates/layout.templ: base HTML shell per UI-SPEC §Base Layout Contract (max-w-5xl container, slate-50 header, slate-200 borders, footer copy, /static/tailwind.css in , /static/htmx.min.js deferred at body end — D-10: HTMX never loaded from a CDN) - templates/index.templ: root page consuming @ui.Card and @ui.Button for the canonical HTMX demo (UI-SPEC §Component Library Contract canonical block) - templates/fragments.templ: TimeFragment renders with RFC3339 UTC timestamp; templ auto-escapes interpolation (T-01-13) - internal/web/handlers.go: HealthzHandler (200 ok / 503 degraded per D-20, 2s Ping timeout), IndexHandler, DemoTimeHandler with injected clock - internal/web/router.go: Pinger interface; NewRouter wires RequestIDMiddleware → RealIP → SlogLoggerMiddleware → Recoverer (D-08 + Pitfall 6 — chi middleware.Logger deliberately NOT registered) and routes /, /healthz, /demo/time, /static/* via http.FileServer (T-01-08 path traversal blocked by http.Dir) All six handler tests + ui package tests are GREEN under default go test. --- backend/internal/web/handlers.go | 53 +++++++++++++++++++++++++++++++ backend/internal/web/router.go | 46 +++++++++++++++++++++++++++ backend/templates/fragments.templ | 10 ++++++ backend/templates/index.templ | 44 +++++++++++++++++++++++++ backend/templates/layout.templ | 33 +++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 backend/internal/web/handlers.go create mode 100644 backend/internal/web/router.go create mode 100644 backend/templates/fragments.templ create mode 100644 backend/templates/index.templ create mode 100644 backend/templates/layout.templ diff --git a/backend/internal/web/handlers.go b/backend/internal/web/handlers.go new file mode 100644 index 0000000..7207023 --- /dev/null +++ b/backend/internal/web/handlers.go @@ -0,0 +1,53 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "backend/templates" +) + +// HealthzHandler returns an HTTP handler that probes the supplied Pinger +// with a 2-second timeout and responds per CONTEXT D-20: 200 + JSON +// `{"status":"ok","db":"ok"}` when reachable, 503 + JSON +// `{"status":"degraded","db":"down"}` otherwise. +func HealthzHandler(pinger Pinger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + w.Header().Set("Content-Type", "application/json") + if err := pinger.Ping(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "degraded", + "db": "down", + }) + return + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "db": "ok", + }) + } +} + +// IndexHandler renders the root page (templates.Index) as text/html. +func IndexHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.Index().Render(r.Context(), w) + } +} + +// DemoTimeHandler renders the HTMX fragment for /demo/time. The `now` clock +// is injected so tests can substitute a deterministic time source; production +// passes time.Now. +func DemoTimeHandler(now func() time.Time) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TimeFragment(now()).Render(r.Context(), w) + } +} diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go new file mode 100644 index 0000000..4f76371 --- /dev/null +++ b/backend/internal/web/router.go @@ -0,0 +1,46 @@ +package web + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" +) + +// Pinger is the contract /healthz uses to probe the data plane. *pgxpool.Pool +// satisfies this interface out of the box, which is why cmd/web passes the +// pool directly to NewRouter (no adapter required). +type Pinger interface { + Ping(ctx context.Context) error +} + +// NewRouter constructs the chi router with the middleware stack locked by +// CONTEXT D-08 + RESEARCH Pattern 2: +// +// 1. RequestIDMiddleware (UUIDv4 — NOT chi's base32 RequestID) +// 2. chi RealIP +// 3. SlogLoggerMiddleware (REPLACES chi's middleware.Logger — Pitfall 6) +// 4. chi Recoverer (after Logger so panics carry request_id) +// +// Routes (Phase 1 only): GET / · GET /healthz · GET /demo/time · GET /static/*. +// staticDir is the on-disk path served at /static/*; path traversal is +// blocked by http.Dir's default behavior (T-01-08). +func NewRouter(pinger Pinger, staticDir string) http.Handler { + r := chi.NewRouter() + r.Use(RequestIDMiddleware) + r.Use(chimw.RealIP) + r.Use(SlogLoggerMiddleware(slog.Default())) + r.Use(chimw.Recoverer) + + r.Get("/", IndexHandler()) + r.Get("/healthz", HealthzHandler(pinger)) + r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() })) + + fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))) + r.Get("/static/*", fs.ServeHTTP) + + return r +} diff --git a/backend/templates/fragments.templ b/backend/templates/fragments.templ new file mode 100644 index 0000000..9d7ddfa --- /dev/null +++ b/backend/templates/fragments.templ @@ -0,0 +1,10 @@ +package templates + +import "time" + +// TimeFragment renders the canonical /demo/time response: a single +// containing an ISO-8601 UTC timestamp. templ auto-escapes the interpolated +// value (T-01-13 defense-in-depth) — never use templ.Raw on user input. +templ TimeFragment(t time.Time) { + { t.UTC().Format(time.RFC3339) } +} diff --git a/backend/templates/index.templ b/backend/templates/index.templ new file mode 100644 index 0000000..4e30fab --- /dev/null +++ b/backend/templates/index.templ @@ -0,0 +1,44 @@ +package templates + +import "backend/internal/web/ui" + +// Index renders the Phase 1 root page: page title, H1, muted subtitle, and +// an @ui.Card containing the canonical HTMX demo (per UI-SPEC §Component +// Library Contract / §HTMX Interaction Pattern). The demo CTA is rendered +// via @ui.Button — pages MUST NOT inline raw Tailwind classes for primitives +// that already exist in the ui package. +templ Index() { + @Layout("Xtablo — Foundation") { +

Xtablo

+

+ Go + HTMX foundation. Sign-in and the Tablos workflow ship in later phases. +

+
+ @ui.Card(nil) { +

HTMX demo

+

+ Click the button to fetch the server time as an HTML fragment. +

+
+ @ui.Button(ui.ButtonProps{ + Label: "Fetch server time", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/demo/time", + "hx-target": "#demo-out", + "hx-swap": "innerHTML", + "hx-indicator": "#demo-spinner", + }, + }) + Loading… +
+
+ No time fetched yet. +
+ } +
+ } +} diff --git a/backend/templates/layout.templ b/backend/templates/layout.templ new file mode 100644 index 0000000..952daf1 --- /dev/null +++ b/backend/templates/layout.templ @@ -0,0 +1,33 @@ +// Package templates owns the server-rendered HTML for the Phase 1 walking +// skeleton. Each *.templ file compiles to a *_templ.go file via `templ +// generate`; generated files are gitignored. +package templates + +// Layout is the base HTML shell every page renders inside. The structural +// classes, container width (max-w-5xl), horizontal padding, header strip, +// footer, and asset references (/static/tailwind.css, /static/htmx.min.js) +// are locked by UI-SPEC §Base Layout Contract and CONTEXT D-10 — do NOT +// load HTMX from a CDN. +templ Layout(title string) { + + + + + + { title } + + + +
+
+
+
+ { children... } +
+
+ Phase 1 · Walking skeleton +
+ + + +}