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, `

`+msg+`

The evidence index lists every fragment in this build.

`) +} + +// handleSearch runs a keyword search. With HTMX it returns just the results +// block; without JavaScript the same handler returns the whole page, so the form +// works in both cases. +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + failed := false + indexed := 0 + if s.built != nil { + indexed = s.built.Indexed + } + var results []search.Result + if s.index != nil { + if n, err := s.index.Count(); err == nil { + indexed = n + } + } + if q != "" { + if s.index == nil { + failed = true + } else { + var err error + results, err = s.index.Query(q, 20) + if err != nil { + failed = true + s.log.Warn("search failed", "query_length", len(q), "error", err) + } + } + } + + view := s.shell.SearchView(indexed, q, results, failed) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Vary", "HX-Request") + + var buf bytes.Buffer + var c interface { + Render(context.Context, io.Writer) error + } = templates.SearchPage(view) + if r.Header.Get("HX-Request") == "true" { + c = templates.SearchResults(view) + } + if err := c.Render(r.Context(), &buf); err != nil { + s.log.Error("render search", "error", err) + http.Error(w, "search is unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = w.Write(buf.Bytes()) + } +} + +// notFound serves the pre-rendered 404 page with a 404 status. +func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { + file := filepath.Join(s.dist, "404.html") + if raw, err := os.ReadFile(file); err == nil && within(s.dist, file) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNotFound) + if r.Method != http.MethodHead { + _, _ = w.Write(raw) + } + return + } + http.Error(w, "not found", http.StatusNotFound) +} + +// assetHandler serves a directory of build output (/static/, /assets/). +func (s *Server) assetHandler(dir string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + prefix := "/" + dir + "/" + rel := strings.TrimPrefix(path.Clean("/"+strings.TrimPrefix(r.URL.Path, prefix)), "/") + if rel == "" || strings.HasPrefix(rel, "..") { + s.notFound(w, r) + return + } + file := filepath.Join(s.dist, dir, filepath.FromSlash(rel)) + if !within(filepath.Join(s.dist, dir), file) { + s.notFound(w, r) + return + } + ctype := contentTypeFor(file) + if !s.serveFile(w, r, file, ctype, assetCache) { + s.notFound(w, r) + } + }) +} + +// serveFile writes one file, with Range and HEAD support. It returns false when +// the file is missing or unreadable, so the caller can choose the error page. +func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, file, ctype, cache string) bool { + f, err := os.Open(file) + if err != nil { + return false + } + defer f.Close() + st, err := f.Stat() + if err != nil || st.IsDir() { + return false + } + h := w.Header() + h.Set("Content-Type", ctype) + h.Set("Cache-Control", cache) + h.Set("Content-Length", strconv.FormatInt(st.Size(), 10)) + http.ServeContent(w, r, st.Name(), st.ModTime(), f) + return true +} + +// within reports whether target is inside root, after cleaning both. Every path +// built from a request goes through here before it reaches the filesystem. +func within(root, target string) bool { + rel, err := filepath.Rel(root, target) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +// contentTypeFor is the small set of types this site publishes. +func contentTypeFor(file string) string { + switch strings.ToLower(filepath.Ext(file)) { + case ".css": + return "text/css; charset=utf-8" + case ".js": + return "text/javascript; charset=utf-8" + case ".svg": + return "image/svg+xml" + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".webp": + return "image/webp" + case ".woff2": + return "font/woff2" + case ".json": + return "application/json; charset=utf-8" + case ".html": + return "text/html; charset=utf-8" + } + return "application/octet-stream" +} + +// statusWriter records the status and size for the request log, and forwards +// Flush so an event stream is not buffered by the middleware. +type statusWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (w *statusWriter) WriteHeader(code int) { + if w.status == 0 { + w.status = code + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := w.ResponseWriter.Write(b) + w.bytes += n + return n, err +} + +func (w *statusWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +func (w *statusWriter) statusOf() int { + if w.status == 0 { + return http.StatusOK + } + return w.status +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..0738bde --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,371 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site" +) + +// The tests run against a real build of the repository's content, because the +// thing under test is "does the server serve what the build wrote". One build is +// shared by every test. +var ( + distDir string + buildErr error +) + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "ai-resume-server-test-") + if err != nil { + panic(err) + } + root, err := filepath.Abs("../..") + if err != nil { + panic(err) + } + s, err := site.New(site.Config{Root: root, OutDir: dir}) + if err == nil { + err = s.Build() + } + buildErr, distDir = err, dir + + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +// newServer starts the site under test. Frames are sent as fast as possible so +// the stream test does not sleep. +func newServer(t *testing.T) *httptest.Server { + t.Helper() + if buildErr != nil { + t.Fatalf("build fixture: %v", buildErr) + } + s, err := New(Config{DistDir: distDir, StreamDelay: time.Millisecond}) + if err != nil { + t.Fatalf("new server: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + ts := httptest.NewServer(s.Handler()) + t.Cleanup(ts.Close) + return ts +} + +func get(t *testing.T, ts *httptest.Server, path string, headers map[string]string) (int, http.Header, string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil) + if err != nil { + t.Fatalf("request %s: %v", path, err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + res, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("get %s: %v", path, err) + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return res.StatusCode, res.Header, string(body) +} + +// TestHealthzSeparatesStaticAndAI is §2.1: readiness for static is its own +// field and does not depend on any AI service. +func TestHealthzSeparatesStaticAndAI(t *testing.T) { + ts := newServer(t) + code, header, body := get(t, ts, "/healthz", nil) + if code != http.StatusOK { + t.Fatalf("healthz status = %d, want 200; body: %s", code, body) + } + if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("healthz content-type = %q, want JSON", ct) + } + var got struct { + Status string `json:"status"` + Static struct { + Ready bool `json:"ready"` + Pages int `json:"pages"` + } `json:"static"` + AI struct { + Ready bool `json:"ready"` + Reason string `json:"reason"` + } `json:"ai"` + Search struct { + Ready bool `json:"ready"` + Documents int `json:"documents"` + } `json:"search"` + } + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("healthz is not JSON: %v\n%s", err, body) + } + if !got.Static.Ready { + t.Error("static.ready = false, want true") + } + if got.Static.Pages == 0 { + t.Error("static.pages = 0, want the built page count") + } + if got.AI.Ready { + t.Error("ai.ready = true, want false in Mode A") + } + if got.AI.Reason == "" { + t.Error("ai.reason is empty; the field must say why AI is not ready") + } + if !got.Search.Ready || got.Search.Documents == 0 { + t.Errorf("search = %+v, want ready with documents", got.Search) + } +} + +// TestPagesArePreRendered checks that the two page kinds render from disk with +// the chrome, the nav and the panel. +func TestPagesArePreRendered(t *testing.T) { + ts := newServer(t) + cases := []struct { + path string + wants []string + }{ + {"/", []string{"Sam Rolfe", `data-stage-driver="route"`, `id="stage-content"`, "Presentation panel", "Skip to content"}}, + {"/edge/", []string{"Edge", `data-stage-driver="route"`, "Breadcrumb"}}, + {"/evidence/", []string{"Evidence index", `class="evidence"`}}, + {"/stream/", []string{`data-stage-driver="stream"`, `sse-connect="/api/stage/stream"`, `sse-swap="stage"`, "/static/sse.js"}}, + } + for _, tc := range cases { + code, header, body := get(t, ts, tc.path, nil) + if code != http.StatusOK { + t.Errorf("%s: status %d, want 200", tc.path, code) + continue + } + if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("%s: content-type %q", tc.path, ct) + } + for _, want := range tc.wants { + if !strings.Contains(body, want) { + t.Errorf("%s: body does not contain %q", tc.path, want) + } + } + } +} + +// TestModeADoesNotShipTheStreamExtension keeps the two modes honestly separate: +// a page only loads htmx's SSE extension when its panel is stream-driven. +func TestModeADoesNotShipTheStreamExtension(t *testing.T) { + ts := newServer(t) + for _, path := range []string{"/", "/edge/", "/evidence/"} { + if _, _, body := get(t, ts, path, nil); strings.Contains(body, "sse.js") { + t.Errorf("%s loads sse.js, but it is not a stream page", path) + } + } +} + +// TestRouteDriverFragment is §2.4's first half: a click loads one pre-rendered +// fragment, and it is a fragment, not a page. +func TestRouteDriverFragment(t *testing.T) { + ts := newServer(t) + code, header, body := get(t, ts, "/stage/doc-edge-overview", nil) + if code != http.StatusOK { + t.Fatalf("fragment status = %d, want 200", code) + } + if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("fragment content-type = %q", ct) + } + if !strings.Contains(body, `class="frag frag-`) { + t.Errorf("fragment body does not look like a fragment: %.120s", body) + } + if strings.Contains(body, "") || strings.Contains(body, "") { + t.Errorf("search %q without HTMX did not return a page", tc.q) + } + if !strings.Contains(body, tc.want) { + t.Errorf("search %q has no result in %s", tc.q, tc.want) + } + + code, _, frag := get(t, ts, "/search?q="+tc.q, map[string]string{"HX-Request": "true"}) + if code != http.StatusOK { + t.Errorf("htmx search %q: status %d", tc.q, code) + continue + } + if strings.Contains(frag, " 100*time.Millisecond { + t.Errorf("first byte in %s, budget is 100ms", elapsed) + } +} diff --git a/internal/server/stream.go b/internal/server/stream.go new file mode 100644 index 0000000..3587d2d --- /dev/null +++ b/internal/server/stream.go @@ -0,0 +1,188 @@ +package server + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage" + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates" +) + +// StreamEvent is the SSE event the panel listens for. The binding rendered by +// stage.SSEDriver uses the same name, so a client needs no configuration. +const StreamEvent = "stage" + +// maxStreamKeys caps how many fragments one connection may ask for, so a +// crafted query cannot turn the demonstration into a long-lived loop. +const maxStreamKeys = 24 + +// handleStream points the stage panel at an event stream. +// +// This is the Phase 2 proof of the §2.4 contract: the panel markup does not +// change, only the binding does. The frames come from the fragments the build +// already rendered, so nothing calls a model and the site still works with +// every AI service stopped. Phase 5 replaces the source behind the same +// interface (stage.Source) and this handler does not change. +func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming is not supported by this server", http.StatusInternalServerError) + return + } + if s.manifest == nil || len(s.manifest.Entries) == 0 { + msg := "the stage manifest is not part of this build" + if s.manifestErr != nil { + s.log.Warn("stream unavailable", "error", s.manifestErr) + } + http.Error(w, msg, http.StatusServiceUnavailable) + return + } + + keys := s.streamKeys(r.URL.Query()) + if len(keys) == 0 { + keys = s.defaultStreamKeys() + } + if len(keys) == 0 { + http.Error(w, "no fragments to stream", http.StatusNotFound) + return + } + + h := w.Header() + h.Set("Content-Type", "text/event-stream; charset=utf-8") + h.Set("Cache-Control", "no-store") + h.Set("Connection", "keep-alive") + h.Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + // A client that reconnects gets a short pause rather than an immediate retry. + if _, err := io.WriteString(w, "retry: 2000\n\n"); err != nil { + return + } + flusher.Flush() + + ctx := r.Context() + source := s.newStreamSource(ctx) + err := source.Stream(ctx, keys, func(f stage.Fragment) error { + var buf bytes.Buffer + if f.Rendered { + // The build already rendered the whole article; send those bytes. + if err := f.Body.Render(ctx, &buf); err != nil { + return err + } + } else { + // A structured source (Phase 5) hands over parts, so the panel + // renders them here with the same component the route driver uses. + if err := templates.FragmentView(f).Render(ctx, &buf); err != nil { + return err + } + } + if err := writeSSE(w, StreamEvent, buf.String()); err != nil { + return err + } + flusher.Flush() + return nil + }) + if err != nil && ctx.Err() == nil { + // Headers are already sent, so there is no status to change. Log it and + // let the panel keep the last fragment it received. + s.log.Warn("stage stream ended early", "error", err) + } +} + +// streamKeys reads the fragment ids a caller asked for. Both `?k=a&k=b` and +// `?k=a,b` work, because both are natural to write by hand. +func (s *Server) streamKeys(q url.Values) []string { + var keys []string + for _, raw := range q["k"] { + for _, part := range strings.Split(raw, ",") { + id := strings.TrimSpace(part) + if id != "" && validFragmentID(id) { + keys = append(keys, id) + } + if len(keys) >= maxStreamKeys { + return keys + } + } + } + return keys +} + +// defaultStreamKeys is the demonstration stream: the six area landing +// fragments, in navigation order. The build writes those first, so the manifest +// alone is enough — the server never reads the content tree. +func (s *Server) defaultStreamKeys() []string { + if s.manifest == nil { + return nil + } + keys := make([]string, 0, 6) + for _, e := range s.manifest.Entries { + if !strings.HasPrefix(e.ID, "doc-") { + continue + } + keys = append(keys, e.ID) + if len(keys) == 6 { + break + } + } + if len(keys) > 0 { + return keys + } + for _, e := range s.manifest.Entries { + keys = append(keys, e.ID) + if len(keys) == 6 { + break + } + } + return keys +} + +// newStreamSource wires the manifest to the files on disk and paces the frames. +func (s *Server) newStreamSource(ctx context.Context) *stage.ManifestSource { + read := func(e stage.Entry) (string, error) { + file := filepath.Join(s.dist, filepath.FromSlash(e.File)) + if !within(s.dist, file) { + return "", fmt.Errorf("fragment %s is outside the build directory", e.ID) + } + raw, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("fragment %s: %w", e.ID, err) + } + return string(raw), nil + } + delay := s.cfg.StreamDelay + frame := func(i int) { + if i == 0 || delay <= 0 { + return + } + select { + case <-time.After(delay): + case <-ctx.Done(): + } + } + return stage.NewManifestSource(s.manifest, read, frame) +} + +// writeSSE writes one event. A payload with newlines is split across data +// lines, which is what the server-sent events format expects. +func writeSSE(w io.Writer, event, data string) error { + var b strings.Builder + b.WriteString("event: ") + b.WriteString(event) + b.WriteByte('\n') + for _, line := range strings.Split(data, "\n") { + b.WriteString("data: ") + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteByte('\n') + _, err := io.WriteString(w, b.String()) + return err +} diff --git a/internal/site/build.go b/internal/site/build.go new file mode 100644 index 0000000..8572e20 --- /dev/null +++ b/internal/site/build.go @@ -0,0 +1,318 @@ +package site + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/a-h/templ" + + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content" + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search" + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web" + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates" +) + +// Published file names. The server and the build agree on these, and nothing +// else does. +const ( + // IndexFile is the SQLite FTS5 index, read-only at request time. + IndexFile = "search.db" + // ManifestFile is the stage fragment index. + ManifestFile = "stage/manifest.json" + // BuildInfoFile records what the last build produced. + BuildInfoFile = "build.json" + // markerFile marks a directory as a previous build, so Build can clean it + // without ever deleting a directory that was not ours. + markerFile = ".build" +) + +// streamEndpoint is where the streaming panel connects. +const streamEndpoint = "/api/stage/stream" + +// BuildInfo is what the build wrote, for /healthz and for the operator. +type BuildInfo struct { + Built time.Time `json:"built"` + Documents int `json:"documents"` + Pages int `json:"pages"` + Fragments int `json:"fragments"` + Indexed int `json:"indexed"` + Artefacts int `json:"artefacts"` +} + +// LoadBuildInfo reads build.json from a published directory. +func LoadBuildInfo(dir string) (*BuildInfo, error) { + raw, err := os.ReadFile(filepath.Join(dir, BuildInfoFile)) + if err != nil { + return nil, err + } + var info BuildInfo + if err := json.Unmarshal(raw, &info); err != nil { + return nil, fmt.Errorf("%s: %w", BuildInfoFile, err) + } + return &info, nil +} + +// Build pre-renders the whole site into OutDir and makes no network call. +// +// It writes: the landing page, every published document at its own URL, the +// evidence index, the search page shell, the streaming panel page, the 404, +// one file per stage fragment plus the manifest, the FTS5 index, the content +// assets and the static files. Nothing is rendered at request time except a +// search with a query in it. +func (s *Site) Build() error { + started := time.Now() + if err := s.prepareOut(); err != nil { + return err + } + + pages := 0 + writePage := func(url string, c templ.Component) error { + pages++ + return renderTo(s.filePathForURL(url), c) + } + + if err := writePage("/", templates.HomePage(s.Home())); err != nil { + return err + } + for _, d := range s.PublicDocs() { + if err := writePage(d.URL, templates.DocPage(s.Doc(d))); err != nil { + return fmt.Errorf("%s: %w", d.Path, err) + } + } + if err := writePage("/evidence/", templates.EvidencePage(s.Evidence())); err != nil { + return err + } + if err := writePage("/search", templates.SearchPage(s.SearchView(len(s.PublicDocs()), "", nil, false))); err != nil { + return err + } + if err := writePage("/stream/", templates.StreamPage(s.Stream(streamEndpoint))); err != nil { + return err + } + if err := writePage("/404.html", templates.NotFoundPage(s.NotFoundPage())); err != nil { + return err + } + + for _, f := range s.Fragments { + if err := writeFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(f.File)), []byte(f.HTML)); err != nil { + return fmt.Errorf("fragment %s: %w", f.ID, err) + } + } + manifest, err := s.Manifest.Marshal() + if err != nil { + return err + } + if err := writeFile(filepath.Join(s.Config.OutDir, ManifestFile), manifest); err != nil { + return err + } + + indexed, err := s.buildIndex() + if err != nil { + return err + } + if err := s.copyAssets(); err != nil { + return err + } + if err := s.copyStatic(); err != nil { + return err + } + info := BuildInfo{ + Built: started.UTC(), + Documents: len(s.PublicDocs()), + Pages: pages, + Fragments: len(s.Fragments), + Indexed: indexed, + Artefacts: s.artefactCount(), + } + raw, err := json.MarshalIndent(info, "", " ") + if err != nil { + return err + } + if err := writeFile(filepath.Join(s.Config.OutDir, BuildInfoFile), append(raw, '\n')); err != nil { + return err + } + return writeFile(filepath.Join(s.Config.OutDir, markerFile), []byte("ai-resume build output\n")) +} + +// filePathForURL maps a site URL onto its file. Directory URLs get index.html +// so the same tree works on any plain file server as well as this binary. +func (s *Site) filePathForURL(url string) string { + rel := strings.TrimPrefix(url, "/") + if rel == "" || strings.HasSuffix(rel, "/") { + rel += "index.html" + } else if path.Ext(rel) == "" { + rel += "/index.html" + } + return filepath.Join(s.Config.OutDir, filepath.FromSlash(rel)) +} + +// prepareOut empties the output directory, but only if it is a directory this +// build wrote before. There is no path by which a mistyped OutDir can delete a +// directory that belongs to something else. +func (s *Site) prepareOut() error { + abs, err := filepath.Abs(s.Config.OutDir) + if err != nil { + return err + } + if abs == string(filepath.Separator) || abs == filepath.Dir(abs) { + return fmt.Errorf("refusing to build into %q", abs) + } + root, err := filepath.Abs(s.Config.Root) + if err != nil { + return err + } + if abs == root { + return fmt.Errorf("refusing to build over the repository root %q", abs) + } + if st, err := os.Stat(abs); err == nil && st.IsDir() { + entries, err := os.ReadDir(abs) + if err != nil { + return err + } + if len(entries) > 0 { + if _, err := os.Stat(filepath.Join(abs, markerFile)); err != nil { + return fmt.Errorf("refusing to clean %s: it is not a previous build (no %s)", abs, markerFile) + } + } + } + if err := os.RemoveAll(abs); err != nil { + return err + } + return os.MkdirAll(abs, 0o755) +} + +// buildIndex writes the FTS5 index and returns how many pages it covers. +func (s *Site) buildIndex() (int, error) { + docs := make([]search.Doc, 0, len(s.Content.Docs)) + for _, d := range s.PublicDocs() { + docs = append(docs, search.Doc{ + ID: d.ID, + URL: d.URL, + Title: d.Title, + Area: d.Area, + Level: d.Level, + Confidence: d.Confidence, + Body: indexBody(d), + }) + } + file := filepath.Join(s.Config.OutDir, IndexFile) + if err := search.Build(file, docs); err != nil { + return 0, err + } + return len(docs), nil +} + +// indexBody is what FTS5 searches: the claim, the rendered body reduced to +// text, and the technology and tag vocabularies so "zigbee" finds a page that +// names it only in front-matter. +func indexBody(d *content.Document) string { + parts := []string{d.Claim, d.Plain} + parts = append(parts, d.Tech...) + parts = append(parts, d.Tags...) + return strings.Join(parts, " ") +} + +// copyAssets publishes content/assets at /assets/. +func (s *Site) copyAssets() error { + src := filepath.Join(s.Config.ContentDir, "assets") + dst := filepath.Join(s.Config.OutDir, "assets") + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return filepath.WalkDir(src, func(p string, e fs.DirEntry, err error) error { + if err != nil { + return err + } + name := e.Name() + if strings.HasPrefix(name, ".") { + if e.IsDir() && p != src { + return filepath.SkipDir + } + return nil + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if e.IsDir() { + return os.MkdirAll(target, 0o755) + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + return writeFile(target, raw) + }) +} + +// copyStatic publishes the vendored htmx, the SSE extension and the site's own +// stylesheet, plus the chroma stylesheet generated from the renderer's style so +// the highlighted code and the page agree on colour. +func (s *Site) copyStatic() error { + dst := filepath.Join(s.Config.OutDir, "static") + err := fs.WalkDir(web.Static, "static", func(p string, e fs.DirEntry, err error) error { + if err != nil { + return err + } + if e.IsDir() { + return nil + } + raw, err := web.Static.ReadFile(p) + if err != nil { + return err + } + return writeFile(filepath.Join(dst, filepath.FromSlash(strings.TrimPrefix(p, "static/"))), raw) + }) + if err != nil { + return err + } + chroma, err := s.Renderer.ChromaCSS() + if err != nil { + return err + } + return writeFile(filepath.Join(dst, "chroma.css"), []byte(chroma)) +} + +// renderTo renders a templ component to a file, creating its directory. +func renderTo(file string, c templ.Component) error { + var buf bytes.Buffer + if err := c.Render(context.Background(), &buf); err != nil { + return err + } + return writeFile(file, buf.Bytes()) +} + +// writeFile writes atomically, so a failed build never leaves half a page. +func writeFile(file string, raw []byte) error { + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + return err + } + tmp := file + ".tmp" + if err := os.WriteFile(tmp, raw, 0o644); err != nil { + return err + } + return os.Rename(tmp, file) +} + +// PublishedPages lists the URLs this site publishes, for tests and for the +// operator running the build. Order is stable. +func (s *Site) PublishedPages() []string { + urls := []string{"/", "/evidence/", "/search", "/stream/", "/404.html"} + for _, d := range s.PublicDocs() { + urls = append(urls, d.URL) + } + sort.Strings(urls) + return urls +} diff --git a/internal/site/fragments.go b/internal/site/fragments.go new file mode 100644 index 0000000..1e69a44 --- /dev/null +++ b/internal/site/fragments.go @@ -0,0 +1,200 @@ +package site + +import ( + "bytes" + "context" + "fmt" + "os" + "path" + + "github.com/a-h/templ" + + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content" + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage" + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates" +) + +// buildFragments renders every stage fragment once, in manifest order: +// +// 1. every document's own fragment, L1 first in area order +// 2. every artefact fragment, in the same document order +// +// The order matters: the first len(content.Areas) entries are the six area +// landing fragments, which is the default stream, and the server reads that +// order out of the manifest without loading the content tree. The panel is a +// content sink, so this is the only place that decides what a fragment is. +// Neither driver renders anything itself. +func (s *Site) buildFragments() error { + order := make([]*content.Document, 0, len(s.Content.Docs)) + for _, a := range content.Areas { + for _, d := range s.PublicInArea(a.Key) { + if d.Level == 1 { + order = append(order, d) + } + } + } + rest := make([]*content.Document, 0, len(s.Content.Docs)) + for _, a := range content.Areas { + for _, d := range s.PublicInArea(a.Key) { + if d.Level != 1 { + rest = append(rest, d) + } + } + } + order = append(order, rest...) + + for _, d := range order { + if err := s.addFragment(s.docFragment(d)); err != nil { + return err + } + } + for _, d := range order { + for i, a := range d.Artefacts { + f, err := s.artefactFragment(d, a, stage.ArtefactFragmentID(d.ID, i)) + if err != nil { + return err + } + if err := s.addFragment(f); err != nil { + return err + } + } + } + return nil +} + +func (s *Site) addFragment(f *Fragment) error { + if _, dup := s.fragByID[f.ID]; dup { + return fmt.Errorf("stage fragment id %q is not unique", f.ID) + } + html, err := renderFragment(f.component()) + if err != nil { + return fmt.Errorf("render stage fragment %s: %w", f.ID, err) + } + f.HTML = html + s.Fragments = append(s.Fragments, f) + s.fragByID[f.ID] = f + s.FragmentsByDoc[f.DocID] = append(s.FragmentsByDoc[f.DocID], f.ID) + s.Manifest.Entries = append(s.Manifest.Entries, stage.Entry{ + ID: f.ID, + DocID: f.DocID, + Title: f.Title, + Kind: f.Kind, + File: f.File, + }) + return nil +} + +// fragment builds the stage value this fragment renders from. +func (f *Fragment) component() stage.Fragment { + out := stage.Fragment{ + ID: f.ID, + Kind: f.Kind, + Title: f.Title, + } + out.Subtitle = f.subtitle + out.Body = f.body + out.Code = f.code + out.Src = f.src + out.Alt = f.alt + out.Links = f.links + out.Confidence = f.confidence + out.DocURL = f.docURL + out.DocTitle = f.docTitle + return out +} + +func renderFragment(f stage.Fragment) (string, error) { + var buf bytes.Buffer + if err := templates.FragmentView(f).Render(context.Background(), &buf); err != nil { + return "", err + } + return buf.String(), nil +} + +// docFragment is the document's own body, shown in the panel when a reader +// asks for the page itself rather than one of its artefacts. +func (s *Site) docFragment(d *content.Document) *Fragment { + area, _ := content.AreaByKey(d.Area) + return &Fragment{ + ID: stage.DocFragmentID(d.ID), + DocID: d.ID, + Kind: stage.KindText, + Title: d.Title, + subtitle: area.Title + " · " + templates.LevelLabel(d.Level), + body: templ.Raw(d.HTML), + confidence: d.Confidence, + docURL: d.URL, + docTitle: "Read the page", + File: "stage/" + stage.DocFragmentID(d.ID) + ".html", + } +} + +// artefactFragment renders one artefact into the kind the panel understands. +func (s *Site) artefactFragment(d *content.Document, a content.Artefact, id string) (*Fragment, error) { + ra, err := s.Content.Resolve(d, a) + if err != nil { + return nil, err + } + f := &Fragment{ + ID: id, + DocID: d.ID, + Kind: ra.Kind, + Title: a.Label, + ArtefactURL: a.URL, + confidence: d.Confidence, + File: "stage/" + id + ".html", + } + if ra.Target != nil { + f.subtitle = ra.Target.Title + f.body = templ.Raw(ra.Target.HTML) + f.confidence = ra.Target.Confidence + f.docURL = ra.Target.URL + f.docTitle = "Open " + ra.Target.Title + return f, nil + } + f.subtitle = ra.Rel + switch ra.Kind { + case stage.KindCode: + src, err := os.ReadFile(ra.File) + if err != nil { + return nil, fmt.Errorf("%s: read code artefact: %w", d.Path, err) + } + var buf bytes.Buffer + lang := content.LanguageFor(ra.File) + if err := s.Renderer.Highlight(&buf, lang, src); err != nil { + return nil, fmt.Errorf("%s: highlight artefact: %w", d.Path, err) + } + f.code = &stage.Code{Language: lang, Label: path.Base(ra.Rel), HTML: templ.Raw(buf.String())} + case stage.KindImage, stage.KindDiagram, stage.KindVideo: + f.src = ra.Href + f.alt = a.Label + default: + f.links = []stage.Link{{Label: a.Label, Href: ra.Href, Note: ra.Rel}} + } + return f, nil +} + +// fragForEntry looks up a build-time fragment by manifest entry. +func (s *Site) fragForEntry(e stage.Entry) (*Fragment, bool) { + f, ok := s.fragByID[e.ID] + return f, ok +} + +// FragmentHTML returns the pre-rendered markup for a fragment id. Both drivers +// serve these bytes: the route driver as a file, the stream driver as an event. +func (s *Site) FragmentHTML(id string) (string, bool) { + f, ok := s.fragByID[id] + if !ok { + return "", false + } + return f.HTML, true +} + +// FragmentsByID is a copy of the fragment index, for tests and the manifest. +func (s *Site) FragmentsByID() map[string]*Fragment { + out := make(map[string]*Fragment, len(s.fragByID)) + for k, v := range s.fragByID { + out[k] = v + } + return out +} diff --git a/internal/site/site.go b/internal/site/site.go new file mode 100644 index 0000000..11ed63f --- /dev/null +++ b/internal/site/site.go @@ -0,0 +1,509 @@ +// Package site assembles the Mode A static site out of content/ and knows how +// to write it to disk. +// +// One assembler, two callers: cmd/build writes the whole site to a directory, +// and cmd/server uses the same view models for the two things that cannot be +// pre-rendered — a keyword search with a query in it, and the streamed stage +// demonstration. Everything else in the running server is a file on disk, so a +// page is never rendered per request and no page needs an AI service. +package site + +import ( + "path/filepath" + "time" + + "github.com/a-h/templ" + + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content" + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search" + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage" + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates" +) + +// Config is where the site is read from and written to. +type Config struct { + // Root is the repository root. Static assets are read from it. + Root string + // ContentDir is the Markdown source of truth. Defaults to Root/content. + ContentDir string + // OutDir is where Build writes the pre-rendered site. Defaults to Root/dist. + OutDir string +} + +func (c *Config) defaults() { + if c.Root == "" { + c.Root = "." + } + if c.ContentDir == "" { + c.ContentDir = filepath.Join(c.Root, "content") + } + if c.OutDir == "" { + c.OutDir = filepath.Join(c.Root, "dist") + } +} + +// Shell is the chrome every page shares. It needs no content tree, so the +// running server can render a search page with only dist/ on disk. +type Shell struct { + // Year is stamped in the footer. + Year int +} + +// NewShell returns the chrome for the current year. +func NewShell() Shell { return Shell{Year: year()} } + +// Nav builds the six-area navigation, marking area as current. +func (Shell) Nav(area string) []templates.NavItem { + items := make([]templates.NavItem, 0, len(content.Areas)) + for _, a := range content.Areas { + items = append(items, templates.NavItem{ + Title: a.Title, + Href: "/" + a.Key + "/", + Gutter: a.Gutter, + Current: a.Key == area, + }) + } + return items +} + +// RouteBinding is the Mode A driver: one pre-rendered fragment per click. +func (Shell) RouteBinding() stage.Binding { + return stage.RouteDriver{BaseURL: "/stage"}.Bind() +} + +// StreamBinding is the Mode B driver: fragments pushed over SSE. +func (Shell) StreamBinding(endpoint string) stage.Binding { + return stage.SSEDriver{Endpoint: endpoint}.Bind() +} + +// LayoutOpts are the per-page chrome values. +type LayoutOpts struct { + Title string + Description string + Area string + Crumbs []templates.Crumb + Binding stage.Binding +} + +// Layout builds the chrome for one page. It never looks at the stage driver +// beyond handing the binding to the template. +func (sh Shell) Layout(o LayoutOpts) templates.LayoutView { + return templates.LayoutView{ + Title: o.Title, + Description: o.Description, + Area: o.Area, + Nav: sh.Nav(o.Area), + Crumbs: o.Crumbs, + Stage: o.Binding, + Year: sh.Year, + } +} + +// SearchView builds the search page. Search is the one Mode A page that reads a +// query, so the server renders it; with JavaScript off the form posts and the +// same page comes back with results. +func (sh Shell) SearchView(indexed int, q string, results []search.Result, failed bool) templates.SearchView { + return templates.SearchView{ + Layout: sh.Layout(LayoutOpts{ + Title: "Search — Sam Rolfe", + Description: "Keyword search across every page of this site. No model, no network call.", + Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Search"}}, + Binding: sh.RouteBinding(), + }), + Query: q, + Results: results, + Indexed: indexed, + Failed: failed, + } +} + +// NotFoundPage is the 404, pre-rendered at build time and also served live. +func (sh Shell) NotFoundPage() templates.NotFoundView { + return templates.NotFoundView{ + Layout: sh.Layout(LayoutOpts{ + Title: "Page not found — Sam Rolfe", + Description: "That page is not here. The six areas and the evidence index are the reliable ways in.", + Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Not found"}}, + Binding: sh.RouteBinding(), + }), + } +} + +// Site is the content tree plus everything derived from it. +type Site struct { + Shell + Config Config + Content *content.Set + Renderer *content.Renderer + + // Fragments are the pre-rendered stage fragments, in manifest order. The + // first len(content.Areas) are the six area landing pages, which is the + // default stream. + Fragments []*Fragment + // Manifest indexes the same fragments the way the server sees them. + Manifest *stage.Manifest + // FragmentsByDoc lists fragment ids per document, in reading order. + FragmentsByDoc map[string][]string + + fragByID map[string]*Fragment +} + +// New loads content/ and renders every page and stage fragment in memory. It +// touches no network and starts no service. +func New(cfg Config) (*Site, error) { + cfg.defaults() + set, err := content.Load(cfg.ContentDir) + if err != nil { + return nil, err + } + r := content.NewRenderer(set) + if err := r.Render(); err != nil { + return nil, err + } + s := &Site{ + Shell: NewShell(), + Config: cfg, + Content: set, + Renderer: r, + Manifest: &stage.Manifest{}, + FragmentsByDoc: map[string][]string{}, + fragByID: map[string]*Fragment{}, + } + if err := s.buildFragments(); err != nil { + return nil, err + } + return s, nil +} + +// PublicDocs returns the published documents: public: true, in navigation order. +func (s *Site) PublicDocs() []*content.Document { + out := make([]*content.Document, 0, len(s.Content.Docs)) + for _, d := range s.Content.Docs { + if d.Public { + out = append(out, d) + } + } + return out +} + +// PublicInArea returns the published documents of one area, L1 first. +func (s *Site) PublicInArea(area string) []*content.Document { + var out []*content.Document + for _, d := range s.PublicDocs() { + if d.Area == area { + out = append(out, d) + } + } + return out +} + +// pageCount counts published pages in an area. +func (s *Site) pageCount(area string) int { return len(s.PublicInArea(area)) } + +// artefactCount counts every resolved artefact on the site. +func (s *Site) artefactCount() int { + n := 0 + for _, d := range s.PublicDocs() { + n += len(d.Artefacts) + } + return n +} + +// Home is the landing page view: the ninety-second read. +func (s *Site) Home() templates.HomeView { + cardArea := func(a content.Area) templates.PillarCardView { + card := templates.PillarCardView{ + Area: a, + Title: a.Title, + Claim: a.Blurb, + Href: "/" + a.Key + "/", + Level: 1, + Pages: s.pageCount(a.Key), + Metrics: []templates.MetricView{}, + Tech: []string{}, + } + if d, ok := s.Content.AreaLanding(a.Key); ok { + card.Claim = d.Claim + card.Confidence = d.Confidence + card.Title = d.Title + for _, m := range d.Metrics { + card.Metrics = append(card.Metrics, templates.MetricView{Label: m.Label, Value: content.MetricValue(m.Value)}) + } + card.Tech = d.Tech + } + return card + } + cards := make([]templates.PillarCardView, 0, len(content.Areas)) + for _, a := range content.Areas { + cards = append(cards, cardArea(a)) + } + return templates.HomeView{ + Layout: s.Layout(LayoutOpts{ + Title: "Sam Rolfe — AI systems, the platforms under them, the hardware at the edge", + Description: content.Positioning + " " + content.SupportLine, + Crumbs: []templates.Crumb{{Label: "Home"}}, + Binding: s.RouteBinding(), + }), + Positioning: content.Positioning, + Support: content.SupportLine, + Location: content.Location, + Cards: cards, + Artifacts: s.artefactCount(), + Pages: len(s.PublicDocs()), + Areas: len(content.Areas), + } +} + +// Doc builds the view for one document at any depth. +func (s *Site) Doc(d *content.Document) templates.DocView { + area, _ := content.AreaByKey(d.Area) + levelLabel := templates.LevelLabel(d.Level) + + view := templates.DocView{ + Layout: s.Layout(LayoutOpts{ + Title: d.Title + " — Sam Rolfe", + Description: content.Truncate(d.Claim, 160), + Area: d.Area, + Crumbs: s.crumbs(d), + Binding: s.RouteBinding(), + }), + Doc: d, + AreaTitle: area.Title, + Role: area.Role, + Body: templates.MarkdownBody(d.HTML), + Metrics: make([]templates.MetricView, 0, len(d.Metrics)), + Tech: d.Tech, + Evidence: s.evidence(d), + Backlinks: s.backlinks(d), + SubPages: s.subPages(d), + LevelLabel: levelLabel, + } + for _, m := range d.Metrics { + view.Metrics = append(view.Metrics, templates.MetricView{Label: m.Label, Value: content.MetricValue(m.Value)}) + } + if d.Area == "history" && d.Level <= 2 { + view.Timeline = s.timeline(d.Area) + } + return view +} + +func (s *Site) crumbs(d *content.Document) []templates.Crumb { + area, _ := content.AreaByKey(d.Area) + out := []templates.Crumb{ + {Label: "Home", Href: "/"}, + {Label: area.Title, Href: "/" + d.Area + "/"}, + } + if d.Level > 1 { + out = append(out, templates.Crumb{Label: d.Title, Href: d.URL}) + } + return out +} + +// evidence turns each artefact into a row that loads the artefact into the +// stage panel and is also a working link. +func (s *Site) evidence(d *content.Document) []templates.LinkView { + out := make([]templates.LinkView, 0, len(d.Artefacts)) + ids := s.FragmentsByDoc[d.ID] + for _, a := range d.Artefacts { + ra, err := s.Content.Resolve(d, a) + if err != nil { + // Load already rejected dangling artefacts; keep going rather than + // render a broken link. + continue + } + conf := d.Confidence + if ra.Target != nil { + conf = ra.Target.Confidence + } + link := templates.LinkView{ + Label: a.Label, + Href: ra.Href, + Kind: ra.Kind, + Confidence: conf, + } + for _, id := range ids { + if f, ok := s.fragByID[id]; ok && f.ArtefactURL == a.URL { + link.HXGet = s.RouteFragmentURL(id) + } + } + out = append(out, link) + } + return out +} + +func (s *Site) backlinks(d *content.Document) []templates.LinkView { + out := make([]templates.LinkView, 0, len(d.Backlinks)) + for _, b := range d.Backlinks { + out = append(out, templates.LinkView{Label: b.Title, Href: b.URL, Kind: templates.LevelLabel(b.Level)}) + } + return out +} + +// subPages is the drill-down list under a page. L1 lists the capability pages +// in its area; every other level lists artefacts in the area that this page +// does not already cite, so no page can become unreachable. +func (s *Site) subPages(d *content.Document) []templates.LinkView { + cited := map[string]bool{} + for _, a := range d.Artefacts { + if ra, err := s.Content.Resolve(d, a); err == nil && ra.Target != nil { + cited[ra.Target.ID] = true + } + } + var out []templates.LinkView + for _, other := range s.PublicInArea(d.Area) { + if other.ID == d.ID || cited[other.ID] { + continue + } + if d.Level == 1 && other.Level != 2 { + continue + } + if d.Level > 1 && other.Level < 3 { + continue + } + out = append(out, templates.LinkView{ + Label: other.Title, + Href: other.URL, + Kind: other.Confidence, + }) + } + return out +} + +// timeline orders an area's deeper pages by period, newest first. +func (s *Site) timeline(area string) []templates.TimelineItem { + var items []templates.TimelineItem + for _, d := range s.PublicInArea(area) { + if d.Level < 2 { + continue + } + items = append(items, templates.TimelineItem{ + Period: d.Period, + Title: d.Title, + Href: d.URL, + Confidence: d.Confidence, + Claim: d.Claim, + }) + } + return items +} + +// Evidence is the one-page index of every artefact on the site. +func (s *Site) Evidence() templates.EvidenceView { + var groups []templates.EvidenceGroup + total := 0 + for _, a := range content.Areas { + group := templates.EvidenceGroup{AreaTitle: a.Title, Href: "/" + a.Key + "/"} + for _, d := range s.PublicInArea(a.Key) { + for _, l := range s.evidence(d) { + group.Rows = append(group.Rows, templates.EvidenceRow{ + Label: d.Title + " · " + l.Label, + Href: l.Href, + HXGet: l.HXGet, + Kind: l.Kind, + Confidence: l.Confidence, + }) + total++ + } + } + if len(group.Rows) > 0 { + groups = append(groups, group) + } + } + return templates.EvidenceView{ + Layout: s.Layout(LayoutOpts{ + Title: "Evidence index — Sam Rolfe", + Description: "Every artefact this site claims, on one page.", + Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Evidence"}}, + Binding: s.RouteBinding(), + }), + Groups: groups, + Total: total, + } +} + +// StreamKeys is the fragment sequence the streaming panel demonstrates: the six +// area landing fragments, in navigation order. No model is involved. +func (s *Site) StreamKeys() []string { + n := len(content.Areas) + if n > len(s.Fragments) { + n = len(s.Fragments) + } + keys := make([]string, 0, n) + for _, f := range s.Fragments[:n] { + keys = append(keys, f.ID) + } + return keys +} + +// Stream is the page that re-points the panel at an event stream. The layout is +// the same as every other page; only the binding differs. +func (s *Site) Stream(endpoint string) templates.StreamView { + keys := s.StreamKeys() + links := make([]templates.LinkView, 0, len(keys)) + for _, id := range keys { + if f, ok := s.fragByID[id]; ok { + links = append(links, templates.LinkView{ + Label: f.Title, + Href: s.DocURL(f.DocID), + HXGet: s.RouteFragmentURL(id), + Kind: f.Kind, + }) + } + } + return templates.StreamView{ + Layout: s.Layout(LayoutOpts{ + Title: "Streaming panel — Sam Rolfe", + Description: "The presentation panel bound to an event stream instead of pre-rendered files.", + Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Streaming panel"}}, + Binding: s.StreamBinding(endpoint), + }), + Frames: len(links), + Fragment: links, + } +} + +// DocURL is the published URL for a document id. +func (s *Site) DocURL(id string) string { + if d, ok := s.Content.ByID(id); ok { + return d.URL + } + return "/" +} + +// RouteFragmentURL is where the route driver's fragment file is served from. +func (s *Site) RouteFragmentURL(id string) string { + return stage.RouteDriver{BaseURL: "/stage"}.FragmentURL(id) +} + +// Fragment is one pre-rendered stage fragment. +type Fragment struct { + ID string + DocID string + Kind string + Title string + // ArtefactURL is the artefact this fragment was built from, if any. It is + // how an evidence row finds the fragment that belongs to it. + ArtefactURL string + // File is relative to the published root, e.g. stage/doc-edge-overview.html. + File string + // HTML is the rendered fragment markup, written to File at build time. + HTML string + + subtitle string + body templ.Component + code *stage.Code + src string + alt string + links []stage.Link + confidence string + docURL string + docTitle string +} + +// Fragment returns a pre-rendered fragment by id. +func (s *Site) Fragment(id string) (*Fragment, bool) { + f, ok := s.fragByID[id] + return f, ok +} + +func year() int { return time.Now().Year() } diff --git a/internal/site/site_test.go b/internal/site/site_test.go new file mode 100644 index 0000000..56b2da7 --- /dev/null +++ b/internal/site/site_test.go @@ -0,0 +1,174 @@ +package site + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content" +) + +// newSite loads the repository's own content. If this fails, every other test +// here would be testing nothing. +func newSite(t *testing.T) *Site { + t.Helper() + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + s, err := New(Config{Root: root, OutDir: filepath.Join(t.TempDir(), "dist")}) + if err != nil { + t.Fatalf("load site: %v", err) + } + return s +} + +// TestSixAreasHaveLandingPages is the DoD line "L1 (6) and L2 (6) pages +// complete": every area has a published level 1 page and at least one level 2 +// page, and every area's L1 fragment is the default stream. +func TestSixAreasHaveLandingPages(t *testing.T) { + s := newSite(t) + if len(content.Areas) != 6 { + t.Fatalf("%d areas, want 6", len(content.Areas)) + } + if got := len(s.StreamKeys()); got != 6 { + t.Fatalf("stream keys = %d, want the six area landing fragments", got) + } + for i, a := range content.Areas { + l1, ok := s.Content.AreaLanding(a.Key) + if !ok { + t.Errorf("%s: no level 1 page", a.Key) + continue + } + if !l1.Public { + t.Errorf("%s: level 1 page %s is not public", a.Key, l1.ID) + } + if f, ok := s.Fragment(s.StreamKeys()[i]); !ok { + t.Errorf("%s: stream key %s is not a fragment", a.Key, s.StreamKeys()[i]) + } else if f.DocID != l1.ID { + t.Errorf("%s: stream fragment %s belongs to %s, want %s", a.Key, f.ID, f.DocID, l1.ID) + } + levels := map[int]int{} + for _, d := range s.PublicInArea(a.Key) { + levels[d.Level]++ + } + if levels[2] == 0 { + t.Errorf("%s: no level 2 pages", a.Key) + } + if levels[3] == 0 { + t.Errorf("%s: no level 3 pages", a.Key) + } + } +} + +// TestEveryArtefactIsReachableWithinThreeClicks is acceptance test C4 against +// the view models: landing → area → artefact page is three clicks, and every +// artefact has a working link. +func TestEveryArtefactIsReachableWithinThreeClicks(t *testing.T) { + s := newSite(t) + for _, area := range content.Areas { + l1, ok := s.Content.AreaLanding(area.Key) + if !ok { + continue + } + linkedFromL1 := map[string]bool{} + for _, l := range s.Doc(l1).SubPages { + linkedFromL1[l.Href] = true + } + for _, l := range s.Doc(l1).Evidence { + linkedFromL1[l.Href] = true + } + + for _, d := range s.PublicInArea(area.Key) { + if d.Level == 1 { + continue + } + if d.Level == 2 { + if !linkedFromL1[d.URL] { + t.Errorf("%s/level 2 page %s is not linked from the area landing page", area.Key, d.URL) + } + continue + } + // Level 3: linked from an L2 page's drill-down or evidence table. + reachable := false + for _, l2 := range s.PublicInArea(area.Key) { + if l2.Level != 2 { + continue + } + view := s.Doc(l2) + for _, l := range view.SubPages { + if l.Href == d.URL { + reachable = true + } + } + for _, l := range view.Evidence { + if l.Href == d.URL { + reachable = true + } + } + } + if !reachable { + t.Errorf("%s/level 3 page %s is not linked from any level 2 page", area.Key, d.URL) + } + } + } +} + +// TestEveryArtefactHasAStageFragment keeps the panel honest: if an evidence row +// offers hx-get, the fragment it names must exist. +func TestEveryArtefactHasAStageFragment(t *testing.T) { + s := newSite(t) + for _, d := range s.PublicDocs() { + for _, l := range s.evidence(d) { + if l.HXGet == "" { + t.Errorf("%s: evidence %q has no stage fragment", d.ID, l.Label) + continue + } + id := strings.TrimPrefix(l.HXGet, "/stage/") + if _, ok := s.Fragment(id); !ok { + t.Errorf("%s: evidence %q points at missing fragment %q", d.ID, l.Label, id) + } + } + } +} + +// TestBuildWritesEveryPublishedPage runs the real build once and checks the +// files the server depends on. +func TestBuildWritesEveryPublishedPage(t *testing.T) { + s := newSite(t) + if err := s.Build(); err != nil { + t.Fatalf("Build: %v", err) + } + for _, url := range s.PublishedPages() { + file := s.filePathForURL(url) + if !strings.HasPrefix(file, s.Config.OutDir) { + t.Errorf("%s: output escapes the build directory", url) + } + if _, err := os.ReadFile(file); err != nil { + t.Errorf("%s: %v", url, err) + } + } + for _, name := range []string{IndexFile, ManifestFile, BuildInfoFile, "static/site.css", "static/htmx.min.js", "static/chroma.css"} { + if _, err := os.ReadFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(name))); err != nil { + t.Errorf("build output %s: %v", name, err) + } + } + info, err := LoadBuildInfo(s.Config.OutDir) + if err != nil { + t.Fatalf("build info: %v", err) + } + if info.Pages == 0 || info.Fragments == 0 || info.Indexed == 0 { + t.Errorf("build info is empty: %+v", info) + } + // The manifest order is the server's default stream: six document + // fragments first, and they are the six area landings. + if len(s.Manifest.Entries) < 6 { + t.Fatalf("manifest has %d entries", len(s.Manifest.Entries)) + } + for _, e := range s.Manifest.Entries[:6] { + if !strings.HasPrefix(e.ID, "doc-") { + t.Errorf("manifest entry %s is not a document fragment; the default stream must be the six areas", e.ID) + } + } +} diff --git a/internal/stage/stage.go b/internal/stage/stage.go index 98a9ff1..d195671 100644 --- a/internal/stage/stage.go +++ b/internal/stage/stage.go @@ -38,6 +38,16 @@ const PanelID = "stage-content" // PanelTarget is the CSS selector for PanelID. func PanelTarget() string { return "#" + PanelID } +// DocFragmentID is the id of the fragment that carries a document's own body. +// The shape lives here, with the rest of the panel contract, so that a row in an +// evidence table, a pre-rendered file name and a streamed frame all agree on it. +func DocFragmentID(docID string) string { return "doc-" + docID } + +// ArtefactFragmentID is the id of the i-th artefact in a document. It is +// derived from the document and the artefact's position, so it stays stable +// across builds and a saved link keeps working when the content is re-rendered. +func ArtefactFragmentID(docID string, i int) string { return fmt.Sprintf("art-%s-%02d", docID, i+1) } + // Mode is how a panel receives content. type Mode string @@ -77,6 +87,11 @@ type Fragment struct { DocURL string DocTitle string Confidence string + // Rendered marks Body as a complete fragment: the build already rendered + // the article and everything inside it, so a transport writes these bytes + // as-is instead of wrapping them again. A source that hands over structured + // content leaves it false and the panel renders the fragment normally. + Rendered bool } // Binding is everything the Stage component needs to know about its driver. @@ -293,11 +308,10 @@ func (s *ManifestSource) Stream(ctx context.Context, keys []string, send func(Fr if s.Frame != nil { s.Frame(i) } - frag := Fragment{ID: e.ID, Kind: e.Kind, Title: e.Title, Body: templ.Raw(html)} + frag := Fragment{ID: e.ID, Kind: e.Kind, Title: e.Title, Body: templ.Raw(html), Rendered: true} if err := send(frag); err != nil { return err } } return nil } - diff --git a/internal/stage/stage_test.go b/internal/stage/stage_test.go new file mode 100644 index 0000000..1e57673 --- /dev/null +++ b/internal/stage/stage_test.go @@ -0,0 +1,148 @@ +package stage + +import ( + "context" + "strings" + "testing" +) + +// TestRouteDriverBindsWithoutTransport checks §2.4: in route mode the panel +// carries no stream wiring at all, because the trigger elements do the work. +func TestRouteDriverBindsWithoutTransport(t *testing.T) { + d := RouteDriver{BaseURL: "/stage"} + if d.Mode() != ModeRoute { + t.Fatalf("Mode() = %q, want %q", d.Mode(), ModeRoute) + } + b := d.Bind() + if b.Mode != ModeRoute { + t.Errorf("binding mode = %q", b.Mode) + } + if b.TargetSelector() != PanelTarget() { + t.Errorf("target = %q, want %q", b.TargetSelector(), PanelTarget()) + } + for _, k := range []string{"hx-ext", "sse-connect", "sse-swap"} { + if _, ok := b.Attributes()[k]; ok { + t.Errorf("route binding has %q; route mode must not open a stream", k) + } + } + if got := d.FragmentURL("doc-x"); got != "/stage/doc-x" { + t.Errorf("FragmentURL = %q", got) + } +} + +// TestSSEDriverChangesOnlyTheBinding is the design contract: the same panel, +// the same target, a different transport. +func TestSSEDriverChangesOnlyTheBinding(t *testing.T) { + sse := SSEDriver{Endpoint: "/api/stage/stream"}.Bind() + route := RouteDriver{}.Bind() + + if sse.TargetSelector() != route.TargetSelector() { + t.Errorf("stream targets %q, route targets %q; the panel must not move", + sse.TargetSelector(), route.TargetSelector()) + } + if sse.Mode == route.Mode { + t.Fatal("stream and route bindings have the same mode") + } + attrs := sse.Attributes() + want := map[string]string{ + "hx-ext": "sse", + "sse-connect": "/api/stage/stream", + "sse-swap": "stage", + "hx-swap": "innerHTML", + } + for k, v := range want { + if got := attrs[k]; got != v { + t.Errorf("attribute %s = %v, want %q", k, got, v) + } + } + if attrs["data-stage-driver"] != string(ModeStream) { + t.Errorf("driver marker = %v", attrs["data-stage-driver"]) + } +} + +// TestManifestSourceServesPreRenderedFragments checks the Phase 2 source: it +// expands a document id, keeps order and marks the payload as already rendered +// so a transport does not wrap the article twice. +func TestManifestSourceServesPreRenderedFragments(t *testing.T) { + m := &Manifest{Entries: []Entry{ + {ID: "doc-a", DocID: "a", Kind: KindText, Title: "A", File: "stage/doc-a.html"}, + {ID: "art-a-01", DocID: "a", Kind: KindCode, Title: "code", File: "stage/art-a-01.html"}, + {ID: "doc-b", DocID: "b", Kind: KindText, Title: "B", File: "stage/doc-b.html"}, + }} + read := func(e Entry) (string, error) { return "
" + e.ID + "
", nil } + src := NewManifestSource(m, read, nil) + + var got []Fragment + err := src.Stream(context.Background(), []string{"a"}, func(f Fragment) error { + got = append(got, f) + return nil + }) + if err != nil { + t.Fatalf("Stream: %v", err) + } + if len(got) != 2 { + t.Fatalf("streamed %d fragments for doc a, want 2", len(got)) + } + for _, f := range got { + if !f.Rendered { + t.Errorf("fragment %s is not marked Rendered; a transport would wrap it again", f.ID) + } + } + if got[0].ID != "art-a-01" || got[1].ID != "doc-a" { + t.Errorf("order = %s,%s; wanted the stable manifest order", got[0].ID, got[1].ID) + } +} + +// TestManifestSourceStopsOnCancel checks that a disconnected visitor stops the +// stream instead of leaving a goroutine producing frames (acceptance test N7). +func TestManifestSourceStopsOnCancel(t *testing.T) { + m := &Manifest{Entries: []Entry{ + {ID: "doc-a", DocID: "a", Kind: KindText, File: "stage/doc-a.html"}, + {ID: "doc-b", DocID: "b", Kind: KindText, File: "stage/doc-b.html"}, + }} + read := func(e Entry) (string, error) { return "
", nil } + ctx, cancel := context.WithCancel(context.Background()) + src := NewManifestSource(m, read, func(i int) { + if i == 0 { + cancel() + } + }) + + sent := 0 + err := src.Stream(ctx, nil, func(Fragment) error { sent++; return nil }) + if err == nil { + t.Fatal("Stream returned nil after cancellation") + } + if sent >= len(m.Entries) { + t.Errorf("sent all %d frames after cancellation; the stream ignored the context", sent) + } +} + +// TestFragmentIDsAreStable keeps the link between an evidence row and its +// fragment stable across builds. +func TestFragmentIDsAreStable(t *testing.T) { + if DocFragmentID("edge-fleet") != "doc-edge-fleet" { + t.Error("document fragment id changed shape") + } + if ArtefactFragmentID("edge-fleet", 0) != "art-edge-fleet-01" { + t.Errorf("artefact fragment id = %q", ArtefactFragmentID("edge-fleet", 0)) + } +} + +// TestKindsAreTheWholeVocabulary guards §2.4: the panel can show six things. +func TestKindsAreTheWholeVocabulary(t *testing.T) { + kinds := []string{KindText, KindCode, KindImage, KindVideo, KindDiagram, KindLinks} + seen := map[string]bool{} + for _, k := range kinds { + if strings.TrimSpace(k) == "" { + t.Fatal("empty fragment kind") + } + if seen[k] { + t.Errorf("duplicate fragment kind %q", k) + } + seen[k] = true + } + if len(kinds) != 6 { + t.Errorf("%d fragment kinds, want 6", len(kinds)) + } +} diff --git a/internal/web/assets.go b/internal/web/assets.go new file mode 100644 index 0000000..71c1a4a --- /dev/null +++ b/internal/web/assets.go @@ -0,0 +1,12 @@ +// Package web embeds the browser-side assets: htmx, the SSE extension and the +// stylesheet. They are compiled into the binary, so the built site has no +// dependency on a bundler, a CDN or the working directory. +package web + +import "embed" + +// Static holds everything served from /static/. The build publishes it +// verbatim, and adds a chroma.css generated from the Markdown renderer's style. +// +//go:embed static +var Static embed.FS diff --git a/internal/web/static/site.css b/internal/web/static/site.css new file mode 100644 index 0000000..ef5e464 --- /dev/null +++ b/internal/web/static/site.css @@ -0,0 +1,1360 @@ +/* + * Ai Resume — the whole stylesheet. + * + * Dark by default, one accent, no web fonts (the system stack is faster and the + * page must render before any network request beyond this file). Everything + * here is layout and colour; no content decisions live in CSS. + * + * Sections: tokens · base · chrome · nav · stage panel · pages · prose · + * evidence · search · footer · responsive · print. + */ + +/* ---- tokens ------------------------------------------------------------ */ + +:root { + --bg: #0b0d10; + --surface: #12161b; + --surface-2: #171d24; + --line: #242c35; + --line-strong: #33404d; + --text: #e8ecf1; + --text-dim: #b3bcc8; + --text-muted: #8d97a5; + --accent: #6ee7c6; + --accent-ink: #04211a; + --link: #8ecbff; + --link-hover: #b9dfff; + + --ok: #56d364; + --info: #79c0ff; + --warn: #e3b341; + --bad: #ff7b72; + --faded: #9aa4b2; + + --radius: 10px; + --radius-sm: 6px; + --pad: clamp(1rem, 2.2vw, 2rem); + --measure: 68ch; + --nav-w: 17rem; + --stage-w: minmax(22rem, 30rem); + + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + + color-scheme: dark; +} + +/* ---- base -------------------------------------------------------------- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; + scrollbar-gutter: stable; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 1rem; + line-height: 1.6; + text-rendering: optimizeLegibility; +} + +img, +svg, +video { + max-width: 100%; + height: auto; +} + +a { + color: var(--link); + text-decoration: none; + border-bottom: 1px solid color-mix(in srgb, var(--link) 35%, transparent); +} + +a:hover { + color: var(--link-hover); + border-bottom-color: currentColor; +} + +h1, +h2, +h3, +h4 { + line-height: 1.2; + margin: 0 0 0.5rem; + font-weight: 650; + letter-spacing: -0.011em; +} + +h1 { + font-size: clamp(1.75rem, 3.2vw, 2.6rem); +} + +h2 { + font-size: clamp(1.25rem, 2vw, 1.6rem); +} + +h3 { + font-size: 1.1rem; +} + +code, +kbd, +pre, +samp { + font-family: var(--font-mono); + font-size: 0.9em; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 3px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + left: 0.5rem; + top: -3rem; + z-index: 50; + padding: 0.5rem 0.9rem; + background: var(--accent); + color: var(--accent-ink); + border-radius: var(--radius-sm); + font-weight: 650; +} + +.skip-link:focus { + top: 0.5rem; +} + +/* ---- chrome ------------------------------------------------------------ */ + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: grid; + grid-template-columns: var(--nav-w) minmax(0, 1fr) auto; + gap: var(--pad); + align-items: center; + padding: 0.7rem var(--pad); + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); +} + +.brand { + display: flex; + align-items: center; + gap: 0.7rem; + color: var(--text); + border-bottom: 0; +} + +.brand-mark { + display: grid; + place-items: center; + width: 2.1rem; + height: 2.1rem; + border-radius: 8px; + border: 1px solid var(--line-strong); + background: var(--surface-2); + color: var(--accent); + font-family: var(--font-mono); + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.brand-text { + display: flex; + flex-direction: column; + font-weight: 650; + line-height: 1.15; +} + +.brand-sub { + font-size: 0.72rem; + font-weight: 500; + color: var(--text-muted); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.nav-disclosure { + display: none; +} + +.nav-rail { + display: flex; + gap: 0.15rem; + overflow-x: auto; + scrollbar-width: none; +} + +.nav-rail::-webkit-scrollbar { + display: none; +} + +.nav-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.7rem; + border-radius: var(--radius-sm); + border-bottom: 0; + color: var(--text-dim); + white-space: nowrap; + font-size: 0.9rem; +} + +.nav-link:hover { + background: var(--surface-2); + color: var(--text); +} + +.nav-current { + background: var(--surface-2); + color: var(--text); + box-shadow: inset 0 -2px 0 var(--accent); +} + +.nav-gutter { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +.nav-current .nav-gutter { + color: var(--accent); +} + +/* ---- shell ------------------------------------------------------------- */ + +.shell { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--stage-w); + gap: var(--pad); + align-items: start; + max-width: 96rem; + margin: 0 auto; + padding: var(--pad); +} + +.main { + min-width: 0; +} + +.main > * + * { + margin-top: 1.5rem; +} + +.crumbs ol { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.82rem; + color: var(--text-muted); +} + +.crumbs li { + display: flex; + gap: 0.35rem; + align-items: center; +} + +.crumb-sep { + color: var(--line-strong); +} + +/* ---- search form ------------------------------------------------------- */ + +.search { + display: flex; + gap: 0.4rem; + min-width: 0; +} + +.search input[type="search"] { + flex: 1 1 auto; + min-width: 0; + padding: 0.5rem 0.7rem; + background: var(--surface); + color: var(--text); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + font: inherit; + font-size: 0.9rem; +} + +.search input[type="search"]::placeholder { + color: var(--text-muted); +} + +.search button { + padding: 0.5rem 0.9rem; + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + font: inherit; + font-size: 0.9rem; + cursor: pointer; +} + +.search button:hover { + background: var(--line); +} + +.nav-search { + justify-self: end; + width: min(24rem, 100%); +} + +.hero-search, +.page-search { + margin-top: 1.25rem; + width: min(34rem, 100%); +} + +/* ---- stage panel ------------------------------------------------------- */ + +.stage { + position: sticky; + top: 4.6rem; + display: flex; + flex-direction: column; + max-height: calc(100vh - 6rem); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; +} + +.stage-head { + padding: 0.8rem 1rem; + border-bottom: 1px solid var(--line); + background: var(--surface-2); +} + +.stage-title { + margin: 0; + font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); +} + +.stage-driver { + margin: 0.2rem 0 0; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--accent); +} + +.stage-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 1rem; + min-height: 12rem; +} + +.stage-empty { + color: var(--text-muted); +} + +.stage-empty-title { + margin: 0 0 0.35rem; + color: var(--text-dim); + font-weight: 600; +} + +.stage-hint { + margin: 0; + font-size: 0.9rem; +} + +.stage-foot { + display: flex; + justify-content: space-between; + gap: 0.5rem; + padding: 0.6rem 1rem; + border-top: 1px solid var(--line); + font-size: 0.78rem; +} + +.stage-foot a { + border-bottom: 0; + color: var(--text-muted); +} + +.stage-foot a:hover { + color: var(--link); +} + +.frag-head { + margin-bottom: 0.75rem; +} + +.frag-kind { + margin: 0; + font-family: var(--font-mono); + font-size: 0.66rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent); +} + +.frag-title { + margin: 0.15rem 0 0; + font-size: 1.05rem; +} + +.frag-sub { + margin: 0.2rem 0 0; + font-size: 0.85rem; + color: var(--text-muted); +} + +.frag-foot { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; + margin-top: 1rem; + padding-top: 0.7rem; + border-top: 1px solid var(--line); + font-size: 0.8rem; +} + +.frag-doc { + border-bottom: 0; +} + +.frag-links { + margin: 0; + padding-left: 1.1rem; +} + +.frag-note { + display: block; + font-size: 0.78rem; + color: var(--text-muted); +} + +.frag-text > :first-child { + margin-top: 0; +} + +.frag-text { + font-size: 0.94rem; +} + +.frag-text h2 { + font-size: 1.05rem; + margin-top: 1.2rem; +} + +.frag-text h3 { + font-size: 0.98rem; + margin-top: 1rem; +} + +/* ---- pages ------------------------------------------------------------- */ + +.hero { + padding: clamp(1.5rem, 4vw, 3rem); + background: linear-gradient(160deg, var(--surface-2), var(--surface) 60%); + border: 1px solid var(--line); + border-radius: calc(var(--radius) + 4px); +} + +.hero-kicker { + margin: 0 0 0.6rem; + font-family: var(--font-mono); + font-size: 0.74rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent); +} + +.hero-title { + margin: 0; + max-width: 30ch; + font-size: clamp(1.9rem, 4.4vw, 3.2rem); + letter-spacing: -0.02em; +} + +.hero-support { + margin: 0.7rem 0 0; + font-size: clamp(1rem, 1.6vw, 1.2rem); + color: var(--text-dim); +} + +.hero-facts { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1.1rem; + margin: 1.2rem 0 0; + padding: 0; + list-style: none; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-muted); +} + +.hero-facts span::before { + content: "▸ "; + color: var(--line-strong); +} + +.pillars { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); + gap: 1rem; +} + +.pillar { + display: flex; + flex-direction: column; + padding: 1.1rem 1.2rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.pillar:hover { + border-color: var(--line-strong); +} + +.pillar-kicker { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0 0 0.4rem; + font-size: 0.74rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.pillar-gutter { + font-family: var(--font-mono); + color: var(--accent); +} + +.pillar-title { + margin: 0; + font-size: 1.25rem; +} + +.pillar-title a { + color: var(--text); + border-bottom: 0; +} + +.pillar-title a:hover { + color: var(--link-hover); +} + +.pillar-claim { + margin: 0.5rem 0 0; + color: var(--text-dim); + font-size: 0.94rem; +} + +.pillar-foot { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + align-items: center; + margin: auto 0 0; + padding-top: 0.9rem; + font-size: 0.8rem; + color: var(--text-muted); +} + +.pillar-go { + font-weight: 600; + border-bottom: 0; +} + +.pillar-count { + font-family: var(--font-mono); + font-size: 0.74rem; +} + +.metrics { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0.8rem 0 0; + padding: 0; + list-style: none; +} + +.chip { + display: inline-flex; + gap: 0.35rem; + align-items: baseline; + padding: 0.2rem 0.55rem; + background: var(--surface-2); + border: 1px solid var(--line-strong); + border-radius: 999px; + font-size: 0.78rem; +} + +.chip strong { + color: var(--accent); + font-family: var(--font-mono); + font-weight: 600; +} + +.chip span { + color: var(--text-muted); +} + +.tech { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin: 0.8rem 0 0; + padding: 0; + list-style: none; +} + +.tech li { + padding: 0.12rem 0.45rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-dim); +} + +.honesty { + padding: 1.2rem 1.4rem; + background: var(--surface); + border: 1px solid var(--line); + border-left: 3px solid var(--accent); + border-radius: var(--radius); +} + +.honesty h2 { + margin-top: 0; + font-size: 1.05rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); +} + +.honesty ul { + margin: 0; + padding-left: 1.1rem; + color: var(--text-dim); +} + +.doc-head { + padding-bottom: 1.1rem; + border-bottom: 1px solid var(--line); +} + +.doc-kicker { + margin: 0 0 0.4rem; + font-family: var(--font-mono); + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +.doc-title { + margin: 0; +} + +.doc-claim { + margin: 0.6rem 0 0; + max-width: var(--measure); + font-size: 1.08rem; + color: var(--text-dim); +} + +.doc-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + margin-top: 0.9rem; +} + +.chip-plain { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.74rem; +} + +.doc-evidence, +.doc-sub, +.doc-back, +.doc-timeline { + padding-top: 1.2rem; + border-top: 1px solid var(--line); +} + +.section-note { + margin: 0.2rem 0 0.9rem; + font-size: 0.86rem; + color: var(--text-muted); +} + +.drill-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.4rem; +} + +.drill-list li { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: baseline; + justify-content: space-between; + padding: 0.45rem 0.6rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} + +.drill-kind { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-muted); +} + +.doc-foot { + margin-top: 1.5rem; + padding-top: 0.9rem; + border-top: 1px solid var(--line); + font-size: 0.8rem; + color: var(--text-muted); +} + +.doc-foot p { + margin: 0.15rem 0; +} + +/* ---- confidence labels ------------------------------------------------- */ + +.conf { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.04em; + padding: 0.08rem 0.4rem; + border: 1px solid currentColor; + border-radius: var(--radius-sm); + white-space: nowrap; +} + +.conf-delivered { + color: var(--ok); +} + +.conf-running { + color: var(--info); +} + +.conf-in-progress { + color: var(--warn); +} + +.conf-abandoned { + color: var(--bad); +} + +.conf-historic { + color: var(--faded); +} + +/* ---- evidence table ---------------------------------------------------- */ + +.evidence { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.evidence caption { + text-align: left; + color: var(--text-muted); +} + +.evidence th, +.evidence td { + padding: 0.5rem 0.6rem; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: top; +} + +.evidence thead th { + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--line-strong); +} + +.evidence tbody tr:hover { + background: var(--surface); +} + +.evidence .kind { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-muted); +} + +.evidence-group + .evidence-group { + margin-top: 1.6rem; +} + +.evidence-group h2 { + font-size: 1rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-dim); +} + +/* ---- timeline ---------------------------------------------------------- */ + +.timeline { + margin: 0; + padding: 0; + list-style: none; + border-left: 1px solid var(--line-strong); +} + +.timeline-item { + position: relative; + padding: 0 0 1.1rem 1.1rem; +} + +.timeline-item::before { + content: ""; + position: absolute; + left: -4px; + top: 0.55rem; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} + +.timeline-period { + margin: 0; + font-family: var(--font-mono); + font-size: 0.74rem; + color: var(--text-muted); +} + +.timeline-body h3 { + margin: 0.15rem 0 0.3rem; + font-size: 1rem; +} + +.timeline-body p { + margin: 0 0 0.4rem; + font-size: 0.9rem; + color: var(--text-dim); +} + +/* ---- search results ---------------------------------------------------- */ + +.search-count { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-muted); +} + +.search-empty { + color: var(--text-dim); +} + +.results { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.8rem; +} + +.result { + padding: 0.9rem 1rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.result h2 { + margin: 0; + font-size: 1.05rem; +} + +.result-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin: 0.35rem 0; + font-size: 0.78rem; +} + +.result-snippet { + margin: 0; + font-size: 0.88rem; + color: var(--text-dim); +} + +.result-snippet mark, +mark { + background: color-mix(in srgb, var(--accent) 25%, transparent); + color: var(--text); + padding: 0 0.15em; + border-radius: 3px; +} + +/* ---- prose (server-rendered Markdown) ---------------------------------- */ + +.prose { + max-width: var(--measure); + font-size: 1.02rem; +} + +.prose > :first-child { + margin-top: 0; +} + +.prose h2 { + margin-top: 2rem; + padding-top: 0.8rem; + border-top: 1px solid var(--line); + font-size: 1.3rem; +} + +.prose h3 { + margin-top: 1.5rem; + font-size: 1.08rem; + color: var(--text); +} + +.prose p, +.prose li { + color: var(--text-dim); +} + +.prose strong { + color: var(--text); +} + +.prose ul, +.prose ol { + padding-left: 1.3rem; +} + +.prose li + li { + margin-top: 0.25rem; +} + +.prose blockquote { + margin: 1.2rem 0; + padding: 0.6rem 1rem; + border-left: 3px solid var(--line-strong); + background: var(--surface); + color: var(--text-dim); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.prose code { + padding: 0.1em 0.35em; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 4px; + color: var(--text); +} + +.prose pre { + margin: 1.2rem 0; + padding: 0.9rem 1rem; + overflow-x: auto; + background: #0d1117; + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.prose pre code { + padding: 0; + background: none; + border: 0; +} + +.prose table { + width: 100%; + border-collapse: collapse; + margin: 1.2rem 0; + font-size: 0.92rem; +} + +.prose th, +.prose td { + padding: 0.45rem 0.6rem; + border-bottom: 1px solid var(--line); + text-align: left; +} + +.prose th { + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.prose hr { + margin: 2rem 0; + border: 0; + border-top: 1px solid var(--line); +} + +.prose img { + border: 1px solid var(--line); + border-radius: var(--radius); +} + +/* code exhibits, shared by the panel and the page */ + +figure.code { + margin: 1rem 0 0; + background: #0d1117; + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; +} + +figure.code figcaption { + display: flex; + justify-content: space-between; + gap: 0.6rem; + padding: 0.45rem 0.7rem; + border-bottom: 1px solid var(--line); + font-family: var(--font-mono); + font-size: 0.74rem; + color: var(--text-muted); +} + +figure.code .code-lang { + color: var(--accent); +} + +figure.code pre { + margin: 0; + padding: 0.8rem 0.9rem; + overflow-x: auto; + border: 0; + border-radius: 0; +} + +figure.media { + margin: 0; +} + +figure.media figcaption { + margin-top: 0.4rem; + font-size: 0.8rem; + color: var(--text-muted); +} + +/* ---- footer ------------------------------------------------------------ */ + +.footer { + max-width: 96rem; + margin: 0 auto; + padding: 1.6rem var(--pad) 2.4rem; + border-top: 1px solid var(--line); + font-size: 0.84rem; + color: var(--text-muted); +} + +.footer p { + margin: 0.35rem 0; +} + +.footer-line { + color: var(--text-dim); +} + +.footer-links { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.footer-copy { + margin-left: auto; + font-family: var(--font-mono); + font-size: 0.74rem; +} + +/* ---- responsive: nav collapses below 900 px ---------------------------- */ + +@media (max-width: 1180px) { + .shell { + grid-template-columns: minmax(0, 1fr); + } + + .stage { + position: static; + max-height: none; + } +} + +@media (max-width: 900px) { + .topbar { + grid-template-columns: 1fr auto; + grid-template-areas: + "brand toggle" + "search search"; + row-gap: 0.6rem; + } + + .brand { + grid-area: brand; + } + + .nav-search { + grid-area: search; + width: 100%; + justify-self: stretch; + } + + .nav-rail { + display: none; + } + + .nav-disclosure { + grid-area: toggle; + display: block; + position: relative; + justify-self: end; + } + + .nav-toggle { + padding: 0.45rem 0.8rem; + background: var(--surface-2); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + font-size: 0.9rem; + list-style: none; + } + + .nav-toggle::-webkit-details-marker { + display: none; + } + + .nav-disclosure[open] .nav { + position: absolute; + right: 0; + top: calc(100% + 0.4rem); + z-index: 30; + display: grid; + min-width: 15rem; + padding: 0.4rem; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgb(0 0 0 / 45%); + } + + .nav-disclosure .nav { + display: none; + } + + .nav-disclosure[open] .nav { + display: grid; + } + + .nav-link { + white-space: normal; + } +} + +@media (max-width: 620px) { + .pillars { + grid-template-columns: 1fr; + } + + .evidence thead { + display: none; + } + + .evidence tr { + display: grid; + gap: 0.15rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--line); + } + + .evidence td { + border: 0; + padding: 0.1rem 0; + } + + .timeline { + border-left: 0; + } + + .timeline-item { + padding-left: 0; + } + + .timeline-item::before { + display: none; + } +} + +/* ---- print ------------------------------------------------------------- */ + +@media print { + :root { + color-scheme: light; + } + + body { + background: #fff; + color: #111; + font-size: 11pt; + } + + .topbar, + .nav, + .nav-disclosure, + .stage, + .search, + .skip-link, + .footer-links, + .hero-search, + .page-search { + display: none !important; + } + + .shell { + display: block; + padding: 0; + } + + .main { + max-width: none; + } + + .hero, + .pillar, + .result, + .honesty, + .drill-list li, + .stage, + .prose blockquote { + background: #fff; + border-color: #bbb; + } + + h1, + h2, + h3, + .prose strong, + .doc-title { + color: #000; + } + + .prose p, + .prose li, + .doc-claim, + .pillar-claim, + .timeline-body p, + .result-snippet { + color: #222; + } + + a { + color: #000; + border-bottom: 0; + } + + /* Print the destination of a link, so a paper copy keeps its evidence. */ + .prose a[href^="/"]::after, + .evidence a[href^="/"]::after, + .drill-list a[href^="/"]::after { + content: " (" attr(href) ")"; + font-family: var(--font-mono); + font-size: 8pt; + color: #555; + } + + .prose h2 { + page-break-after: avoid; + } + + .pillar, + .timeline-item, + .result, + .evidence tr { + page-break-inside: avoid; + } + + .conf { + border-color: #999; + color: #000; + } + + figure.code pre, + .prose pre { + background: #f6f6f6; + border: 1px solid #ccc; + white-space: pre-wrap; + word-break: break-word; + } + + .footer { + border-top: 1px solid #bbb; + color: #333; + } +} diff --git a/internal/web/templates/components.templ b/internal/web/templates/components.templ index 5496b34..a3d0ca0 100644 --- a/internal/web/templates/components.templ +++ b/internal/web/templates/components.templ @@ -3,8 +3,6 @@ package templates import ( "strconv" - "github.com/a-h/templ" - "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage" ) @@ -154,7 +152,7 @@ templ FragmentView(f stage.Fragment) { switch f.Kind { case stage.KindText: -
{ f.Body }
+
@f.Body
case stage.KindCode: if f.Code != nil {
@@ -299,7 +297,7 @@ templ Timeline(items []TimelineItem) { templ CodeBlock(c stage.Code) {
{ c.Label }{ c.Language }
- { c.HTML } + @c.HTML
} diff --git a/internal/web/templates/pages.templ b/internal/web/templates/pages.templ index 180074b..c0dfd30 100644 --- a/internal/web/templates/pages.templ +++ b/internal/web/templates/pages.templ @@ -1,10 +1,6 @@ package templates -import ( - "strconv" - - "github.com/a-h/templ" -) +import "strconv" // HomePage is the landing page: the 90-second read. templ HomePage(v HomeView) { @@ -55,10 +51,10 @@ templ DocPage(v DocView) { { v.Doc.Period } } for _, m := range v.Metrics { -
  • + { m.Value } { m.Label } -
  • + } if len(v.Tech) > 0 { @@ -178,7 +174,7 @@ templ SearchResults(v SearchView) { L{ strconv.Itoa(r.Level) } { r.Confidence }

    -

    { SafeSnippet(r.Snippet) }

    +

    @SafeSnippet(r.Snippet)

    } 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.