feat(01-03): templ layout/index/fragments + handlers + chi router
- 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 <head>, /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 <span> 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.
This commit is contained in:
parent
36e96015f5
commit
3a12f8f47d
5 changed files with 186 additions and 0 deletions
53
backend/internal/web/handlers.go
Normal file
53
backend/internal/web/handlers.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
46
backend/internal/web/router.go
Normal file
46
backend/internal/web/router.go
Normal file
|
|
@ -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
|
||||
}
|
||||
10
backend/templates/fragments.templ
Normal file
10
backend/templates/fragments.templ
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package templates
|
||||
|
||||
import "time"
|
||||
|
||||
// TimeFragment renders the canonical /demo/time response: a single <span>
|
||||
// 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) {
|
||||
<span class="text-slate-900">{ t.UTC().Format(time.RFC3339) }</span>
|
||||
}
|
||||
44
backend/templates/index.templ
Normal file
44
backend/templates/index.templ
Normal file
|
|
@ -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") {
|
||||
<h1 class="text-[28px] font-semibold leading-tight">Xtablo</h1>
|
||||
<p class="mt-2 text-base text-slate-600">
|
||||
Go + HTMX foundation. Sign-in and the Tablos workflow ship in later phases.
|
||||
</p>
|
||||
<div class="mt-8">
|
||||
@ui.Card(nil) {
|
||||
<h2 class="text-xl font-semibold leading-snug">HTMX demo</h2>
|
||||
<p class="mt-2 text-base text-slate-600">
|
||||
Click the button to fetch the server time as an HTML fragment.
|
||||
</p>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
@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",
|
||||
},
|
||||
})
|
||||
<span id="demo-spinner" class="htmx-indicator text-sm text-slate-600">Loading…</span>
|
||||
</div>
|
||||
<div id="demo-out" class="mt-4 text-base text-slate-600">
|
||||
No time fetched yet.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
33
backend/templates/layout.templ
Normal file
33
backend/templates/layout.templ
Normal file
|
|
@ -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) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{ title }</title>
|
||||
<link rel="stylesheet" href="/static/tailwind.css"/>
|
||||
</head>
|
||||
<body class="min-h-screen bg-white text-slate-900 antialiased">
|
||||
<header class="bg-slate-50 border-b border-slate-200">
|
||||
<div class="mx-auto max-w-5xl px-4 sm:px-6 py-4"></div>
|
||||
</header>
|
||||
<main class="mx-auto max-w-5xl px-4 sm:px-6 py-8">
|
||||
{ children... }
|
||||
</main>
|
||||
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
|
||||
Phase 1 · Walking skeleton
|
||||
</footer>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
Loading…
Reference in a new issue