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.