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.