From 84fc585c912c3fc3be98024d5a63c4cecb38edff Mon Sep 17 00:00:00 2001
From: Sam Rolfe
Date: Mon, 26 Oct 2026 20:42:52 +1100
Subject: [PATCH] =?UTF-8?q?Phase=202:=20Mode=20A=20static=20site=20?=
=?UTF-8?q?=E2=80=94=20build,=20serve,=20search,=20stage=20panel?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
One Go binary serves a pre-rendered site. No Node, no bundler, no node_modules:
htmx and the SSE extension are vendored files, Markdown and code highlighting
run at build time (goldmark + chroma), and the server only writes files.
Implements docs/phase-2-static-site.md:
2.1 service skeleton cmd/build (content -> dist), cmd/server (dist -> HTTP),
env-configured, 127.0.0.1:8096, /healthz with separate
static / ai / search readiness, --health-check probe,
deploy/compose.yml + deploy/Dockerfile at 120m
2.2 rendering templ components (Layout, Nav, PillarCard, Stage,
EvidenceTable, CodeBlock, Timeline, Footer);
`make generate` is a build step and is byte-stable
2.3 navigation six-area rail, disclosure under 900px,
breadcrumbs, aria-current, L1/L2/L3 with artefact pages
2.4 stage panel internal/stage owns the contract; RouteDriver (fragment
swap) and SSEDriver (event stream) differ only in the
Binding, never in layout. fragment ids live in the
contract so route files, links and streamed frames agree
2.5 search SQLite FTS5 over titles and body, rebuilt by the build;
one handler returns a page without HTMX, a fragment with
it. Visitors' FTS operators are never honoured
2.6 quality floor print stylesheet, JS-off navigation, focus states,
semantic landmarks, dark default; security headers
Definition of Done evidence (see issue AUD-142):
A1 all pages 200 in a network namespace with no external route
one binary serves the site; make dist writes 45 pages, 86 fragments, 40 docs
templ generate is deterministic (md5 identical across runs)
6 L1 + 6 L2 pages, artefact pages reachable in <= 3 clicks
FTS5 returns sensible hits for zigbee / signage / routing (tested)
Lighthouse 100 performance and 100 accessibility, mobile and desktop
first byte well inside 100ms over loopback (test)
panel renders from a route driver and re-points at the stream untouched
print legible: /edge/ renders to a 3-page PDF with the print stylesheet
Also fixes found while finishing the build:
- reject "." / ".." path segments before ServeMux redirects them into a file
outside the asset directory (/static/../build.json now 404, was 307->200)
- make test is the real gate: go test ./... plus the validator self-test
---
.gitignore | 1 +
Makefile | 52 +-
README.md | 25 +-
cmd/build/main.go | 58 +
cmd/server/main.go | 155 +++
deploy/Dockerfile | 29 +
deploy/compose.yml | 48 +
internal/content/markdown.go | 4 +-
internal/search/search_test.go | 89 ++
internal/server/server.go | 602 ++++++++++
internal/server/server_test.go | 371 +++++++
internal/server/stream.go | 188 ++++
internal/site/build.go | 318 ++++++
internal/site/fragments.go | 200 ++++
internal/site/site.go | 509 +++++++++
internal/site/site_test.go | 174 +++
internal/stage/stage.go | 18 +-
internal/stage/stage_test.go | 148 +++
internal/web/assets.go | 12 +
internal/web/static/site.css | 1360 +++++++++++++++++++++++
internal/web/templates/components.templ | 6 +-
internal/web/templates/pages.templ | 12 +-
internal/web/templates/view.go | 28 +-
23 files changed, 4373 insertions(+), 34 deletions(-)
create mode 100644 cmd/build/main.go
create mode 100644 cmd/server/main.go
create mode 100644 deploy/Dockerfile
create mode 100644 deploy/compose.yml
create mode 100644 internal/search/search_test.go
create mode 100644 internal/server/server.go
create mode 100644 internal/server/server_test.go
create mode 100644 internal/server/stream.go
create mode 100644 internal/site/build.go
create mode 100644 internal/site/fragments.go
create mode 100644 internal/site/site.go
create mode 100644 internal/site/site_test.go
create mode 100644 internal/stage/stage_test.go
create mode 100644 internal/web/assets.go
create mode 100644 internal/web/static/site.css
diff --git a/.gitignore b/.gitignore
index bc3cbf6..241b286 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ __pycache__/
*.pyc
# build output
+/bin/
/dist/
/current/
*.test
diff --git a/Makefile b/Makefile
index db3d4de..25e9ba1 100644
--- a/Makefile
+++ b/Makefile
@@ -1,19 +1,61 @@
# Ai Resume - build entry points.
#
-# `make validate` is the single gate: it is what CI and deploy.sh call.
+# `make validate` is the content gate: it is what CI and deploy.sh call.
# It exits 0 only on a clean content tree.
+#
+# `make build` is the site gate: it generates the templ components, compiles the
+# two small binaries and writes the pre-rendered site into dist/. One binary
+# (bin/resume-server) serves the whole site; there is no bundler and no Node.
PYTHON ?= python3
VALIDATOR := $(PYTHON) tools/validate_content.py
+GO ?= go
+TEMPL ?= templ
-.PHONY: validate test help
+BIN := bin
+DIST := dist
+
+.PHONY: build dist generate dev test validate validate-self-test fmt vet clean help
help:
- @echo "make validate validate content/** front-matter against the frozen schema"
- @echo "make test run the validator self-test (fixtures under test/fixtures/)"
+ @echo "make build templ generate, compile bin/, no dist"
+ @echo "make dist build, then pre-render content into dist/"
+ @echo "make dev pre-render into dist/ and serve it on 127.0.0.1:8096"
+ @echo "make generate run templ generate (generated files are not committed)"
+ @echo "make test go test ./... plus the validator self-test"
+ @echo "make validate validate content/** front-matter against the frozen schema"
+ @echo "make fmt vet gofmt / go vet over cmd and internal"
+ @echo "make clean remove bin/ and dist/"
+
+build: generate
+ @mkdir -p $(BIN)
+ $(GO) build -trimpath -buildvcs=false -o $(BIN)/resume-build ./cmd/build
+ $(GO) build -trimpath -buildvcs=false -o $(BIN)/resume-server ./cmd/server
+
+dist: build
+ @$(BIN)/resume-build --dist $(DIST)
+
+dev: dist
+ @RESUME_DIST=$(DIST) $(BIN)/resume-server
+
+generate:
+ @command -v $(TEMPL) >/dev/null || { echo "templ is not on PATH; run this inside 'nix develop'"; exit 1; }
+ @$(TEMPL) generate
+
+test: generate validate-self-test
+ @$(GO) test ./...
validate:
@$(VALIDATOR)
-test:
+validate-self-test:
@$(VALIDATOR) --self-test
+
+fmt:
+ @gofmt -l -w cmd internal
+
+vet:
+ @$(GO) vet ./...
+
+clean:
+ @rm -rf $(BIN) $(DIST)
diff --git a/README.md b/README.md
index 467c24e..af1507a 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,27 @@ This folder **is** the git working copy of `sam/ai-resume`. One repo, one produc
Previously held an abandoned Vite/React scaffold. It was replaced by this build spec; the history is retained because it honestly records the false start, and it is referenced in the site's "alternatives considered" content.
+## Build and run
+
+The site is one Go binary serving pre-rendered HTML. There is no Node, no bundler and no
+`node_modules`; `go.sum` is the only dependency lock.
+
+```sh
+nix develop # go, templ, sqlite3, make, curl
+make validate # content gate: front-matter against the frozen schema
+make test # go test ./... plus the validator self-test
+make dist # templ generate → bin/ → pre-rendered dist/
+make dev # build, then serve on http://127.0.0.1:8096
+```
+
+`bin/resume-build` reads `content/` and writes `dist/` (pages, stage fragments, the fragment
+manifest, the SQLite FTS5 index, assets). `bin/resume-server` serves `dist/` and never reads
+`content/`, never calls a model, and never opens a socket to another service — that is what
+keeps acceptance test A1 true.
+
+The two things that are not files are `/search` (FTS5 over page titles and body text) and
+`/api/stage/stream` (the presentation panel driven by an event stream instead of a route).
+
## Ground rules
- **Do not touch other systems.** No stopping containers, no editing other projects, no `nixos-rebuild`, no Caddy changes except the single block specified in Phase 3 — and that one Sam applies.
@@ -48,8 +69,8 @@ Previously held an abandoned Vite/React scaffold. It was replaced by this build
## Current state
- [x] Repo prepared, scaffold retired, build spec written
-- [ ] Phase 1 — content source of truth
-- [ ] Phase 2 — static site
+- [x] Phase 1 — content source of truth (40 documents across six areas, `make validate`)
+- [x] Phase 2 — static site (`make dist`, 45 pages, 86 stage fragments, FTS5 search)
- [ ] Phase 3 — public deployment
- [ ] Phase 4 — corpus
- [ ] Phase 5 — AI overlay
diff --git a/cmd/build/main.go b/cmd/build/main.go
new file mode 100644
index 0000000..2ac1d43
--- /dev/null
+++ b/cmd/build/main.go
@@ -0,0 +1,58 @@
+// Command build pre-renders the whole site into a directory.
+//
+// It reads content/ and writes dist/: every page as HTML, one file per stage
+// fragment, the fragment manifest, the SQLite FTS5 index, the assets and the
+// static files. No network call is made, and the output is what the server
+// serves — Mode A never renders a page per request.
+//
+// Configuration is from the environment, so the same binary is used by
+// `make build`, by CI and by the container image:
+//
+// RESUME_ROOT repository root (default ".")
+// RESUME_CONTENT content directory (default $RESUME_ROOT/content)
+// RESUME_DIST output directory (default $RESUME_ROOT/dist)
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
+)
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(flag.CommandLine.Output(), "usage: build [--root DIR] [--content DIR] [--dist DIR]\n")
+ fmt.Fprintf(flag.CommandLine.Output(), "environment: RESUME_ROOT, RESUME_CONTENT, RESUME_DIST\n")
+ }
+ root := flag.String("root", env("RESUME_ROOT", "."), "repository root")
+ contentDir := flag.String("content", env("RESUME_CONTENT", ""), "content directory")
+ distDir := flag.String("dist", env("RESUME_DIST", ""), "output directory")
+ flag.Parse()
+
+ cfg := site.Config{Root: *root, ContentDir: *contentDir, OutDir: *distDir}
+ s, err := site.New(cfg)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "build: %v\n", err)
+ os.Exit(1)
+ }
+ if err := s.Build(); err != nil {
+ fmt.Fprintf(os.Stderr, "build: %v\n", err)
+ os.Exit(1)
+ }
+ info, err := site.LoadBuildInfo(s.Config.OutDir)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "build: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Printf("built %d pages, %d stage fragments, %d indexed documents into %s\n",
+ info.Pages, info.Fragments, info.Indexed, s.Config.OutDir)
+}
+
+func env(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 0000000..256e009
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,155 @@
+// Command server serves the site the build wrote.
+//
+// It reads a directory produced by cmd/build and serves it on loopback. It does
+// not read content/, does not need Qdrant, OmniRoute or any other service, and
+// makes no outbound connection, so every page it serves works with the AI stack
+// stopped (acceptance test A1).
+//
+// Configuration comes from the environment so the same binary works under
+// `make dev`, systemd and compose:
+//
+// RESUME_ADDR listen address (default 127.0.0.1:8096)
+// RESUME_DIST build directory (default "dist")
+// RESUME_STREAM_DELAY pacing for the streamed panel, e.g. 350ms
+//
+// Flags with the same names override the environment for local work.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/server"
+)
+
+func main() {
+ addr := flag.String("addr", env("RESUME_ADDR", server.DefaultAddr), "listen address")
+ dist := flag.String("dist", env("RESUME_DIST", server.DefaultDist), "build directory")
+ streamDelay := flag.Duration("stream-delay", envDuration("RESUME_STREAM_DELAY", server.DefaultStreamDelay), "delay between streamed panel frames")
+ healthCheck := flag.Bool("health-check", false, "ask a running server for /healthz and exit 0 only if static is ready")
+ flag.Usage = func() {
+ fmt.Fprintf(flag.CommandLine.Output(), "usage: server [--addr HOST:PORT] [--dist DIR] [--stream-delay DURATION] [--health-check]\n")
+ fmt.Fprintf(flag.CommandLine.Output(), "environment: RESUME_ADDR, RESUME_DIST, RESUME_STREAM_DELAY\n")
+ }
+ flag.Parse()
+
+ // The container image has no shell and no curl, so the health probe is this
+ // binary talking to itself (deploy/compose.yml healthcheck).
+ if *healthCheck {
+ if err := probe(*addr); err != nil {
+ fmt.Fprintf(os.Stderr, "health check failed: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println("static ready")
+ return
+ }
+
+ log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
+
+ site, err := server.New(server.Config{
+ Addr: *addr,
+ DistDir: *dist,
+ StreamDelay: *streamDelay,
+ Logger: log,
+ })
+ if err != nil {
+ log.Error("start failed", "error", err)
+ os.Exit(1)
+ }
+ defer site.Close()
+
+ // No WriteTimeout on purpose: the stage stream keeps the response open for
+ // as long as it is producing frames. Reads are bounded.
+ srv := &http.Server{
+ Addr: *addr,
+ Handler: site.Handler(),
+ ReadHeaderTimeout: 5 * time.Second,
+ IdleTimeout: 120 * time.Second,
+ MaxHeaderBytes: 64 << 10,
+ ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ go func() {
+ <-ctx.Done()
+ log.Info("shutting down")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ log.Warn("shutdown", "error", err)
+ }
+ }()
+
+ log.Info("listening", "addr", *addr, "dist", *dist)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Error("serve failed", "error", err)
+ os.Exit(1)
+ }
+}
+
+// probe asks a running server whether the static site is ready. Only the static
+// field decides the exit code: Mode A must report ready with every AI service
+// stopped (acceptance test A1).
+func probe(addr string) error {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ return err
+ }
+ if host == "" || host == "0.0.0.0" || host == "::" {
+ host = "127.0.0.1"
+ }
+ client := &http.Client{Timeout: 3 * time.Second}
+ res, err := client.Get("http://" + net.JoinHostPort(host, port) + "/healthz")
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return fmt.Errorf("/healthz returned %s", res.Status)
+ }
+ var body struct {
+ Static struct {
+ Ready bool `json:"ready"`
+ } `json:"static"`
+ }
+ if err := json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&body); err != nil {
+ return fmt.Errorf("decode /healthz: %w", err)
+ }
+ if !body.Static.Ready {
+ return errors.New("static is not ready")
+ }
+ return nil
+}
+
+func env(key, fallback string) string {
+ if v, ok := os.LookupEnv(key); ok && v != "" {
+ return v
+ }
+ return fallback
+}
+
+func envDuration(key string, fallback time.Duration) time.Duration {
+ v, ok := os.LookupEnv(key)
+ if !ok || v == "" {
+ return fallback
+ }
+ d, err := time.ParseDuration(v)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s=%q is not a duration: %v\n", key, v, err)
+ os.Exit(2)
+ }
+ return d
+}
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
new file mode 100644
index 0000000..d445652
--- /dev/null
+++ b/deploy/Dockerfile
@@ -0,0 +1,29 @@
+# Runtime image for the Mode A site (docs/phase-2-static-site.md §2.1).
+#
+# The image contains no toolchain: `make build dist` on the build host produces
+# bin/resume-server and dist/, and the image just carries them. That keeps the
+# runtime offline and small, and it means the deployed bytes are the bytes that
+# were built and tested, not a second compile.
+#
+# Build context is the repository root:
+#
+# make build dist
+# docker build -f deploy/Dockerfile -t ai-resume:local .
+#
+# Phase 3 (§3.1) owns the release directory, the compose invocation and the
+# rollback; this file only describes the process that runs.
+
+FROM gcr.io/distroless/static-debian12:nonroot
+
+COPY bin/resume-server /usr/bin/resume-server
+COPY dist/ /srv/site
+
+# 0.0.0.0 is inside the container's own network namespace only. The host
+# publishes the port on 127.0.0.1 (see deploy/compose.yml), which is what keeps
+# Caddy the only public listener.
+ENV RESUME_ADDR=0.0.0.0:8096 \
+ RESUME_DIST=/srv/site
+
+EXPOSE 8096
+USER nonroot:nonroot
+ENTRYPOINT ["/usr/bin/resume-server"]
diff --git a/deploy/compose.yml b/deploy/compose.yml
new file mode 100644
index 0000000..701d09d
--- /dev/null
+++ b/deploy/compose.yml
@@ -0,0 +1,48 @@
+# Container shape for the Mode A site (docs/phase-2-static-site.md §2.1).
+#
+# Values are fixed by the specification and must not drift:
+# mem_limit == memswap_limit == 120m so the app cannot eat the host's swap
+# ports published on 127.0.0.1 only Caddy on .35 is the only public listener
+# logging capped one bad day cannot fill the disk
+#
+# Run it (build the artefacts first — Phase 3's deploy.sh does both):
+#
+# make build dist
+# docker compose -f deploy/compose.yml up -d
+#
+# `curl http://127.0.0.1:8096/healthz` must answer with static ready.
+
+name: ai-resume
+
+services:
+ resume:
+ image: ai-resume:local
+ build:
+ context: ..
+ dockerfile: deploy/Dockerfile
+ restart: unless-stopped
+ mem_limit: 120m
+ memswap_limit: 120m
+ # The app listens on 0.0.0.0 inside the container namespace; the host-side
+ # binding is loopback, so nothing is reachable from the LAN except through
+ # docker-proxy on 127.0.0.1:8096 — which is what Caddy proxies to.
+ ports:
+ - "127.0.0.1:8096:8096"
+ read_only: true
+ security_opt:
+ - no-new-privileges:true
+ logging:
+ driver: json-file
+ options:
+ max-size: 10m
+ max-file: "3"
+ healthcheck:
+ test: ["CMD", "/usr/bin/resume-server", "--health-check"]
+ interval: 30s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
+
+# Phase 4/5 add at most three more containers (Qdrant, Prefect worker, query
+# service) inside the 4-container and 1.0 GiB budget in AGENTS.md §4.3. Mode A
+# needs none of them and must keep working when they are stopped (test A1).
diff --git a/internal/content/markdown.go b/internal/content/markdown.go
index 9f0d51b..0edad44 100644
--- a/internal/content/markdown.go
+++ b/internal/content/markdown.go
@@ -13,14 +13,14 @@ import (
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
- highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark"
+ highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
- "github.com/yuin/goldmark/util"
"github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
)
diff --git a/internal/search/search_test.go b/internal/search/search_test.go
new file mode 100644
index 0000000..636f2f9
--- /dev/null
+++ b/internal/search/search_test.go
@@ -0,0 +1,89 @@
+package search
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestMatchExpressionIsWordsOnly is the security-relevant half of search: FTS5
+// syntax in visitor input is never honoured, so a query cannot shape the index
+// beyond words. It also means "zigbee)" is not an error page.
+func TestMatchExpressionIsWordsOnly(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"zigbee", `"zigbee"*`},
+ {"Zigbee Routers", `"zigbee"* AND "routers"*`},
+ {`zigbee" OR body:`, `"zigbee"* AND "or"* AND "body"*`},
+ {`NEAR(a b)`, `"near"* AND "a"* AND "b"*`},
+ {` `, ``},
+ {`!!!`, ``},
+ {"snapcast-audio", `"snapcast-audio"*`},
+ }
+ for _, tc := range cases {
+ if got := MatchExpression(tc.in); got != tc.want {
+ t.Errorf("MatchExpression(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+// TestBuildAndQuery is the §2.5 DoD check: the FTS5 index returns sensible
+// results for the three terms the phase document names.
+func TestBuildAndQuery(t *testing.T) {
+ indexFile := filepath.Join(t.TempDir(), "search.db")
+ docs := []Doc{
+ {ID: "edge", URL: "/edge/", Title: "Edge & Devices", Area: "edge", Level: 1, Confidence: "running",
+ Body: "Twenty nodes on constrained hardware. Zigbee routers and a LoRa link."},
+ {ID: "platform", URL: "/platform/", Title: "Platform & Infrastructure", Area: "platform", Level: 1, Confidence: "running",
+ Body: "Declarative hosts, networking and storage."},
+ {ID: "products", URL: "/products/", Title: "Products", Area: "products", Level: 1, Confidence: "delivered",
+ Body: "Commercial signage on commodity hardware."},
+ {ID: "ai", URL: "/ai/", Title: "AI & Agentic Systems", Area: "ai", Level: 1, Confidence: "running",
+ Body: "Model routing and cost control through a single gateway."},
+ }
+ if err := Build(indexFile, docs); err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ idx, err := Open(indexFile)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer idx.Close()
+
+ if n, err := idx.Count(); err != nil || n != len(docs) {
+ t.Fatalf("Count = %d, %v; want %d", n, err, len(docs))
+ }
+
+ cases := []struct{ q, wantURL string }{
+ {"zigbee", "/edge/"},
+ {"signage", "/products/"},
+ {"routing", "/ai/"},
+ {"ZIGBEE", "/edge/"},
+ }
+ for _, tc := range cases {
+ results, err := idx.Query(tc.q, 10)
+ if err != nil {
+ t.Errorf("Query(%q): %v", tc.q, err)
+ continue
+ }
+ if len(results) == 0 {
+ t.Errorf("Query(%q) found nothing", tc.q)
+ continue
+ }
+ found := false
+ for _, r := range results {
+ if r.URL == tc.wantURL {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("Query(%q) did not return %s; got %+v", tc.q, tc.wantURL, results)
+ }
+ if !strings.Contains(results[0].Snippet, "") {
+ t.Errorf("Query(%q) snippet has no highlight: %q", tc.q, results[0].Snippet)
+ }
+ }
+
+ if results, err := idx.Query("!!!", 10); err != nil || len(results) != 0 {
+ t.Errorf("Query(no words) = %v, %v; want no results and no error", results, err)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..905b944
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,602 @@
+// Package server serves the pre-rendered Mode A site out of a build directory.
+//
+// Everything a visitor reads is a file the build wrote: the server renders a
+// page only for a keyword search that carries a query, and for the streamed
+// panel demonstration. Nothing here calls a model, opens a socket to another
+// service or reads the content tree, so stopping Qdrant, OmniRoute or any query
+// service cannot change what a visitor sees (acceptance test A1).
+//
+// The two pieces that are not files are the two things the static build cannot
+// pre-render:
+//
+// GET /search?q=… keyword search over the FTS5 index
+// GET /api/stage/stream the stage panel bound to an event stream
+package server
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
+ "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
+ "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
+ "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
+)
+
+// Default configuration. The address is loopback on purpose: Caddy is the only
+// public listener (AGENTS.md §5).
+const (
+ DefaultAddr = "127.0.0.1:8096"
+ DefaultDist = "dist"
+ DefaultStreamDelay = 350 * time.Millisecond
+)
+
+// Cache policies. Pages are rebuilt on deploy and must not be stale across a
+// release, so HTML is revalidated. Unhashed assets get an hour at the origin;
+// Phase 3's Caddy block sets the long immutable cache for the public edge.
+const (
+ pageCache = "no-cache"
+ assetCache = "public, max-age=3600"
+ fragCache = "public, max-age=300"
+)
+
+// Config is what the server needs to start. Every field has a usable default so
+// that the same binary runs under systemd, compose and `make dev`.
+type Config struct {
+ // Addr is the listen address. Default 127.0.0.1:8096.
+ Addr string
+ // DistDir is a directory written by cmd/build. Default "dist".
+ DistDir string
+ // StreamDelay paces the streamed panel demonstration. Default 350ms.
+ StreamDelay time.Duration
+ // Logger receives one line per request. Default: JSON to stdout.
+ Logger *slog.Logger
+}
+
+func (c *Config) defaults() {
+ if c.Addr == "" {
+ c.Addr = DefaultAddr
+ }
+ if c.DistDir == "" {
+ c.DistDir = DefaultDist
+ }
+ if c.StreamDelay <= 0 {
+ c.StreamDelay = DefaultStreamDelay
+ }
+ if c.Logger == nil {
+ c.Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
+ }
+}
+
+// Server serves one build directory.
+type Server struct {
+ cfg Config
+ dist string
+ shell site.Shell
+ log *slog.Logger
+
+ // built is nil when the directory has no readable build.json; the site is
+ // still served, but /healthz reports static as not ready.
+ built *site.BuildInfo
+ builtErr error
+
+ // manifest is nil when the build has no stage fragments. Only the streamed
+ // demonstration needs it.
+ manifest *stage.Manifest
+ manifestErr error
+
+ // index is nil when the FTS5 index could not be opened. Search then says so
+ // and the rest of the site is unaffected.
+ index *search.Index
+ indexErr error
+
+ handler http.Handler
+}
+
+// New opens a build directory. It does not fail when the directory is
+// incomplete: it starts, serves whatever is there and lets /healthz report why
+// it is not ready, which is what a deploy script needs to see.
+func New(cfg Config) (*Server, error) {
+ cfg.defaults()
+ abs, err := filepath.Abs(cfg.DistDir)
+ if err != nil {
+ return nil, err
+ }
+ cfg.DistDir = abs
+ st, err := os.Stat(abs)
+ if err != nil {
+ return nil, fmt.Errorf("build directory %s: %w", abs, err)
+ }
+ if !st.IsDir() {
+ return nil, fmt.Errorf("build directory %s is not a directory", abs)
+ }
+
+ s := &Server{
+ cfg: cfg,
+ dist: abs,
+ shell: site.NewShell(),
+ log: cfg.Logger,
+ }
+
+ s.built, s.builtErr = site.LoadBuildInfo(abs)
+ if s.built == nil && s.builtErr == nil {
+ s.builtErr = errors.New("build.json is missing")
+ }
+ if s.builtErr != nil {
+ s.log.Warn("build metadata unavailable", "dist", abs, "error", s.builtErr)
+ }
+
+ if raw, err := os.ReadFile(filepath.Join(abs, filepath.FromSlash(site.ManifestFile))); err != nil {
+ s.manifestErr = err
+ s.log.Warn("stage manifest unavailable", "dist", abs, "error", err)
+ } else if m, err := stage.LoadManifest(raw); err != nil {
+ s.manifestErr = err
+ s.log.Warn("stage manifest is not readable", "dist", abs, "error", err)
+ } else {
+ s.manifest = m
+ }
+
+ s.index, s.indexErr = search.Open(filepath.Join(abs, site.IndexFile))
+ if s.indexErr != nil {
+ s.log.Warn("search index unavailable", "dist", abs, "error", s.indexErr)
+ }
+
+ s.handler = s.routes()
+ return s, nil
+}
+
+// Handler is the whole site behind one http.Handler.
+func (s *Server) Handler() http.Handler { return s.handler }
+
+// Close releases the search index handle.
+func (s *Server) Close() error {
+ if s.index != nil {
+ return s.index.Close()
+ }
+ return nil
+}
+
+func (s *Server) routes() http.Handler {
+ mux := http.NewServeMux()
+
+ // Liveness and readiness. The AI field is separate and deliberately false:
+ // Mode A never needs a model (docs/phase-2-static-site.md §2.1).
+ mux.HandleFunc("GET /healthz", s.handleHealth)
+
+ // The two things that are not pre-rendered files.
+ mux.HandleFunc("GET /search", s.handleSearch)
+ mux.HandleFunc("GET /api/stage/stream", s.handleStream)
+
+ // Route driver: one pre-rendered fragment per click.
+ mux.HandleFunc("GET /stage/{id}", s.handleFragment)
+
+ // Assets the build copied out of the repository.
+ mux.Handle("GET /static/", s.assetHandler("static"))
+ mux.Handle("GET /assets/", s.assetHandler("assets"))
+
+ // Every other path is a page. "GET /" is the fallback pattern.
+ mux.HandleFunc("GET /", s.handlePage)
+
+ return s.middleware(s.rejectDotSegments(mux))
+}
+
+// rejectDotSegments refuses a request whose path has a "." or ".." segment
+// before http.ServeMux cleans it and answers with a redirect. Without this,
+// "/static/../build.json" is redirected to "/build.json" and served from outside
+// the asset directory. The check runs on URL.Path, which is already percent-
+// decoded, so an encoded "%2e%2e" is caught as well.
+func (s *Server) rejectDotSegments(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if hasDotSegment(r.URL.Path) {
+ s.notFound(w, r)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// hasDotSegment reports whether any segment of p is "." or "..".
+func hasDotSegment(p string) bool {
+ for _, seg := range strings.Split(p, "/") {
+ if seg == "." || seg == ".." {
+ return true
+ }
+ }
+ return false
+}
+
+// middleware adds the security headers, one log line per request, and a panic
+// guard that keeps one bad request from taking the process down.
+func (s *Server) middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ // The policy mirrors the Caddy block in docs/phase-3-deploy.md, so the
+ // browser sees the same rules whether it reaches the origin or the edge.
+ h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ h.Set("X-Frame-Options", "DENY")
+ h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
+
+ rec := &statusWriter{ResponseWriter: w}
+ started := time.Now()
+ defer func() {
+ if p := recover(); p != nil {
+ s.log.Error("request panic", "method", r.Method, "path", r.URL.Path, "panic", fmt.Sprint(p))
+ if rec.status == 0 {
+ http.Error(rec, "internal error", http.StatusInternalServerError)
+ }
+ }
+ s.log.Info("request",
+ "method", r.Method,
+ "path", r.URL.Path,
+ "status", rec.statusOf(),
+ "bytes", rec.bytes,
+ "duration_ms", time.Since(started).Milliseconds(),
+ )
+ }()
+ next.ServeHTTP(rec, r)
+ })
+}
+
+// handleHealth reports static and AI readiness separately. Static readiness is
+// the only thing that decides the status code, because static readiness is what
+// a deploy needs; the AI field says plainly that Mode A does not use one.
+func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+ indexReady := s.index != nil
+ documents := 0
+ if indexReady {
+ if n, err := s.index.Count(); err == nil {
+ documents = n
+ }
+ }
+ if documents == 0 && s.built != nil {
+ documents = s.built.Indexed
+ }
+
+ static := struct {
+ Ready bool `json:"ready"`
+ Pages int `json:"pages,omitempty"`
+ Built string `json:"built,omitempty"`
+ Error string `json:"error,omitempty"`
+ }{Ready: s.staticReady()}
+ if s.built != nil {
+ static.Pages = s.built.Pages
+ static.Built = s.built.Built.Format(time.RFC3339)
+ }
+ if !static.Ready && s.builtErr != nil {
+ static.Error = s.builtErr.Error()
+ } else if !static.Ready {
+ static.Error = "index.html is missing from the build directory"
+ }
+
+ body := struct {
+ Status string `json:"status"`
+ Static any `json:"static"`
+ AI struct {
+ Ready bool `json:"ready"`
+ Reason string `json:"reason"`
+ } `json:"ai"`
+ Search struct {
+ Ready bool `json:"ready"`
+ Documents int `json:"documents,omitempty"`
+ Error string `json:"error,omitempty"`
+ } `json:"search"`
+ }{Static: static}
+ body.AI.Ready = false
+ body.AI.Reason = "Mode A serves pre-rendered pages and calls no model; Mode B reports its own readiness in Phase 5."
+ body.Search.Ready = indexReady
+ body.Search.Documents = documents
+ if s.indexErr != nil {
+ body.Search.Error = s.indexErr.Error()
+ }
+
+ code := http.StatusOK
+ body.Status = "ok"
+ if !static.Ready {
+ code = http.StatusServiceUnavailable
+ body.Status = "static-unavailable"
+ }
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(code)
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ _ = enc.Encode(body)
+}
+
+// staticReady is true when the build actually produced a landing page.
+func (s *Server) staticReady() bool {
+ if s.built == nil {
+ return false
+ }
+ st, err := os.Stat(filepath.Join(s.dist, "index.html"))
+ return err == nil && !st.IsDir()
+}
+
+// handlePage serves a pre-rendered page by URL. Directory URLs map to
+// index.html so the same tree works under any plain file server.
+func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) {
+ rel := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
+ file, ok := s.resolvePage(rel)
+ if !ok {
+ s.notFound(w, r)
+ return
+ }
+ if !s.serveFile(w, r, file, "text/html; charset=utf-8", pageCache) {
+ s.notFound(w, r)
+ }
+}
+
+// resolvePage maps a cleaned URL path onto a file inside the build directory.
+// A file that is not there is not an error: the caller serves the 404 page.
+func (s *Server) resolvePage(rel string) (string, bool) {
+ candidates := make([]string, 0, 3)
+ switch {
+ case rel == "" || strings.HasSuffix(rel, "/"):
+ candidates = append(candidates, rel+"index.html")
+ case path.Ext(rel) == "":
+ candidates = append(candidates, rel, rel+"/index.html", rel+".html")
+ default:
+ candidates = append(candidates, rel)
+ }
+ for _, c := range candidates {
+ full := filepath.Join(s.dist, filepath.FromSlash(c))
+ if !within(s.dist, full) {
+ continue
+ }
+ if st, err := os.Stat(full); err == nil && !st.IsDir() {
+ return full, true
+ }
+ }
+ return "", false
+}
+
+// handleFragment serves the route driver's payload: one pre-rendered stage
+// fragment, selected by the evidence table's hx-get. It is the same bytes the
+// stream driver sends as an event.
+func (s *Server) handleFragment(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if !validFragmentID(id) {
+ s.fragmentNotFound(w, "That fragment is not part of this build.")
+ return
+ }
+ file := filepath.Join(s.dist, "stage", id+".html")
+ if !within(s.dist, file) {
+ s.fragmentNotFound(w, "That fragment is not part of this build.")
+ return
+ }
+ if _, err := os.Stat(file); err != nil {
+ s.fragmentNotFound(w, "That fragment is not part of this build.")
+ return
+ }
+ if !s.serveFile(w, r, file, "text/html; charset=utf-8", fragCache) {
+ s.fragmentNotFound(w, "That fragment could not be read.")
+ }
+}
+
+// validFragmentID keeps ids to the alphabet the build produces, so a request
+// can never build a path of its own.
+func validFragmentID(id string) bool {
+ if id == "" || len(id) > 128 {
+ return false
+ }
+ for _, r := range id {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// fragmentNotFound returns a small HTML fragment, because the panel swaps the
+// response into the page and a full document would nest inside it.
+func (s *Server) fragmentNotFound(w http.ResponseWriter, msg string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = io.WriteString(w, `
}
diff --git a/internal/web/templates/view.go b/internal/web/templates/view.go
index b2e163b..290246f 100644
--- a/internal/web/templates/view.go
+++ b/internal/web/templates/view.go
@@ -1,5 +1,11 @@
// Package templates holds every templ component and the view models they read.
// Layout never branches on the stage driver: it renders the binding it is given.
+//
+// The generated *_templ.go files are not committed; `make generate` writes them
+// from the repository root before every build. There is deliberately no
+// //go:generate directive: templ records the .templ path in its error messages,
+// so generating from a package directory and generating from the root produce
+// different files. One entry point keeps the output byte-identical.
package templates
import (
@@ -91,15 +97,15 @@ type TimelineItem struct {
// PillarCardView is one of the six landing cards.
type PillarCardView struct {
- Area content.Area
- Title string
- Claim string
- Href string
- Metrics []MetricView
- Tech []string
+ Area content.Area
+ Title string
+ Claim string
+ Href string
+ Metrics []MetricView
+ Tech []string
Confidence string
- Level int
- Pages int
+ Level int
+ Pages int
}
// Layout of each page kind.
@@ -151,9 +157,9 @@ type (
// StreamView is the SSE-driven stage demonstration page.
StreamView struct {
- Layout LayoutView
- Frames int
- Fragment []LinkView
+ Layout LayoutView
+ Frames int
+ Fragment []LinkView
}
// NotFoundView is the 404 page.