Compare commits
3 Commits
content/ph
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
| 84fc585c91 | |||
| fd9974d07e | |||
| ea2c722816 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ __pycache__/
|
||||
*.pyc
|
||||
|
||||
# build output
|
||||
/bin/
|
||||
/dist/
|
||||
/current/
|
||||
*.test
|
||||
|
||||
52
Makefile
52
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)
|
||||
|
||||
25
README.md
25
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
|
||||
|
||||
58
cmd/build/main.go
Normal file
58
cmd/build/main.go
Normal file
@@ -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
|
||||
}
|
||||
155
cmd/server/main.go
Normal file
155
cmd/server/main.go
Normal file
@@ -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
|
||||
}
|
||||
29
deploy/Dockerfile
Normal file
29
deploy/Dockerfile
Normal file
@@ -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"]
|
||||
48
deploy/compose.yml
Normal file
48
deploy/compose.yml
Normal file
@@ -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).
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792773339,
|
||||
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
27
flake.nix
Normal file
27
flake.nix
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
description = "Ai Resume — build environment (Go + templ + SQLite). Node, Vite and bundlers are deliberately absent.";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
in {
|
||||
devShells = forAll (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.go
|
||||
pkgs.templ
|
||||
pkgs.sqlite # the sqlite3 CLI, for inspecting the FTS5 index
|
||||
pkgs.gnumake
|
||||
pkgs.curl
|
||||
];
|
||||
shellHook = ''
|
||||
echo "ai-resume dev shell — go $(go version | cut -d' ' -f3), templ $(templ version)"
|
||||
echo "targets: make generate build test dev"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
25
go.mod
Normal file
25
go.mod
Normal file
@@ -0,0 +1,25 @@
|
||||
module gitea.lab.audasmedia.com.au/sam/ai-resume
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/sqlite v1.34.5 // indirect
|
||||
)
|
||||
46
go.sum
Normal file
46
go.sum
Normal file
@@ -0,0 +1,46 @@
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
456
internal/content/content.go
Normal file
456
internal/content/content.go
Normal file
@@ -0,0 +1,456 @@
|
||||
// Package content loads the Markdown source of truth in content/ into documents
|
||||
// that both renderers (Mode A static pages, Mode B corpus) read.
|
||||
//
|
||||
// The schema is frozen; see docs/content-schema.md. This package does not
|
||||
// re-implement the validator (tools/validate_content.py is the gate). It does
|
||||
// enforce the invariants the renderer needs, and it fails loudly rather than
|
||||
// rendering a broken page.
|
||||
package content
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Confidence values. These are displayed on the page and are never promoted.
|
||||
const (
|
||||
ConfidenceDelivered = "delivered"
|
||||
ConfidenceRunning = "running"
|
||||
ConfidenceInProgress = "in-progress"
|
||||
ConfidenceAbandoned = "abandoned"
|
||||
ConfidenceHistoric = "historic"
|
||||
)
|
||||
|
||||
// Area is one of the six top-level navigation areas. Structure lives in code
|
||||
// (it comes from reference/content-map.md, not from the content tree); the
|
||||
// documents that fill it live in content/.
|
||||
type Area struct {
|
||||
Key string
|
||||
Title string
|
||||
Role string
|
||||
Blurb string
|
||||
Gutter string // two-letter mark used by the nav and the stage panel
|
||||
}
|
||||
|
||||
// Areas is the fixed navigation order from reference/content-map.md.
|
||||
var Areas = []Area{
|
||||
{Key: "ai", Title: "AI & Agentic Systems", Role: "AI / LLM Engineer", Gutter: "AI",
|
||||
Blurb: "Harnesses, subagents and skills, memory, model routing and cost, retrieval, voice, guardrails, agent observability."},
|
||||
{Key: "platform", Title: "Platform & Infrastructure", Role: "Platform / DevOps Engineer", Gutter: "PL",
|
||||
Blurb: "Declarative hosts, packaging, a fifty-container Docker estate, networking, storage and restore-tested backups, observability."},
|
||||
{Key: "edge", Title: "Edge & Devices", Role: "Edge / IoT Engineer", Gutter: "ED",
|
||||
Blurb: "Twenty-node fleet on constrained devices, custom radio, solar power, deep sleep, sensors and actuation — and the failures."},
|
||||
{Key: "products", Title: "Products", Role: "", Gutter: "PR",
|
||||
Blurb: "Three active businesses, built and run rather than advised on."},
|
||||
{Key: "history", Title: "History", Role: "", Gutter: "HI",
|
||||
Blurb: "2000–2014: agencies, clients, engagements, and the code that survives from them."},
|
||||
{Key: "about", Title: "About", Role: "", Gutter: "AB",
|
||||
Blurb: "The narrative, the non-IT work that came first, education, and how to get in touch."},
|
||||
}
|
||||
|
||||
// Positioning is the site's single line of introduction.
|
||||
const (
|
||||
Positioning = "Twenty-five years shipping software. Now building agent systems, the platforms under them, and the hardware at the edge."
|
||||
SupportLine = "…and it all runs unattended."
|
||||
Location = "Melbourne"
|
||||
)
|
||||
|
||||
// AreaByKey returns the area with the given key.
|
||||
func AreaByKey(key string) (Area, bool) {
|
||||
for _, a := range Areas {
|
||||
if a.Key == key {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
return Area{}, false
|
||||
}
|
||||
|
||||
// Metric is one approved chip on a page, e.g. {label: "edge nodes", value: 20}.
|
||||
type Metric struct {
|
||||
Label string `yaml:"label"`
|
||||
Value any `yaml:"value"`
|
||||
}
|
||||
|
||||
// String renders the chip value with its label: "20 edge nodes".
|
||||
func (m Metric) String() string {
|
||||
return fmt.Sprintf("%v %s", m.Value, m.Label)
|
||||
}
|
||||
|
||||
// Artefact is a drill-down link. Every URL must resolve to a file in this
|
||||
// repository; nothing external is allowed.
|
||||
type Artefact struct {
|
||||
Type string `yaml:"type"` // repo | image | video | doc
|
||||
Label string `yaml:"label"`
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
// Document is one Markdown file plus its front-matter.
|
||||
type Document struct {
|
||||
ID string `yaml:"id"`
|
||||
Area string `yaml:"area"`
|
||||
Level int `yaml:"level"`
|
||||
Title string `yaml:"title"`
|
||||
Claim string `yaml:"claim"`
|
||||
Confidence string `yaml:"confidence"`
|
||||
Period string `yaml:"period"`
|
||||
Tech []string `yaml:"tech"`
|
||||
Metrics []Metric `yaml:"metrics"`
|
||||
Artefacts []Artefact `yaml:"artefacts"`
|
||||
Public bool `yaml:"public"`
|
||||
LastVerified string `yaml:"last_verified"`
|
||||
Tags []string `yaml:"tags"`
|
||||
|
||||
// Derived by Load.
|
||||
Path string `yaml:"-"` // path relative to the content root, e.g. edge/edge-fleet.md
|
||||
URL string `yaml:"-"` // canonical page URL, e.g. /edge/edge-fleet/
|
||||
HTML string `yaml:"-"` // rendered body
|
||||
Plain string `yaml:"-"` // body reduced to text, for the search index
|
||||
|
||||
bodyRaw string // Markdown body, rendered by the markdown renderer
|
||||
|
||||
// Backlinks are the documents that reference this one as an artefact.
|
||||
Backlinks []*Document `yaml:"-"`
|
||||
}
|
||||
|
||||
// ResolvedArtefact is an Artefact with its target resolved.
|
||||
type ResolvedArtefact struct {
|
||||
Artefact
|
||||
Target *Document // non-nil when the artefact is an L3/L2 document
|
||||
File string // absolute path on disk
|
||||
Rel string // path relative to the content root, e.g. assets/x.svg
|
||||
Href string // site URL: a page URL for documents, /assets/... for files
|
||||
Kind string // Fragment kind: text | code | diagram | image | video | links
|
||||
}
|
||||
|
||||
// Set is every loaded document, indexed.
|
||||
type Set struct {
|
||||
Root string
|
||||
Docs []*Document
|
||||
byID map[string]*Document
|
||||
byRel map[string]*Document
|
||||
}
|
||||
|
||||
// ByID returns the document with the given id.
|
||||
func (s *Set) ByID(id string) (*Document, bool) {
|
||||
d, ok := s.byID[id]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// ByRel returns the document at the given content-relative path.
|
||||
func (s *Set) ByRel(rel string) (*Document, bool) {
|
||||
d, ok := s.byRel[rel]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// InArea returns the public documents for an area, ordered L1, L2, L3.
|
||||
func (s *Set) InArea(key string) []*Document {
|
||||
var out []*Document
|
||||
for _, d := range s.Docs {
|
||||
if d.Area == key {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Level != out[j].Level {
|
||||
return out[i].Level < out[j].Level
|
||||
}
|
||||
return out[i].Title < out[j].Title
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// AreaLanding returns the L1 document for an area.
|
||||
func (s *Set) AreaLanding(key string) (*Document, bool) {
|
||||
for _, d := range s.InArea(key) {
|
||||
if d.Level == 1 {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Load reads every Markdown file under root. Documents with public: false are
|
||||
// loaded (so links to them still resolve) but are not published.
|
||||
func Load(root string) (*Set, error) {
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("content root %q: %w", root, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("content root %q is not a directory", root)
|
||||
}
|
||||
|
||||
s := &Set{Root: root, byID: map[string]*Document{}, byRel: map[string]*Document{}}
|
||||
|
||||
err = filepath.WalkDir(root, func(p string, e os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
if p != root && (strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(name, ".md") || strings.HasPrefix(name, ".") {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, p)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
|
||||
raw, rerr := os.ReadFile(p)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
doc, derr := parse(rel, p, raw)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
if prev, dup := s.byID[doc.ID]; dup {
|
||||
return fmt.Errorf("%s: duplicate id %q (already used by %s)", rel, doc.ID, prev.Path)
|
||||
}
|
||||
doc.URL = PageURL(doc)
|
||||
s.Docs = append(s.Docs, doc)
|
||||
s.byID[doc.ID] = doc
|
||||
s.byRel[rel] = doc
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(s.Docs) == 0 {
|
||||
return nil, fmt.Errorf("content root %q contains no documents", root)
|
||||
}
|
||||
sort.SliceStable(s.Docs, func(i, j int) bool {
|
||||
if s.Docs[i].Area != s.Docs[j].Area {
|
||||
return areaOrder(s.Docs[i].Area) < areaOrder(s.Docs[j].Area)
|
||||
}
|
||||
if s.Docs[i].Level != s.Docs[j].Level {
|
||||
return s.Docs[i].Level < s.Docs[j].Level
|
||||
}
|
||||
return s.Docs[i].ID < s.Docs[j].ID
|
||||
})
|
||||
|
||||
for _, d := range s.Docs {
|
||||
for _, a := range d.Artefacts {
|
||||
ra, rerr := s.Resolve(d, a)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
if ra.Target != nil && ra.Target.ID != d.ID {
|
||||
ra.Target.Backlinks = append(ra.Target.Backlinks, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func areaOrder(key string) int {
|
||||
for i, a := range Areas {
|
||||
if a.Key == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(Areas)
|
||||
}
|
||||
|
||||
func parse(rel, abs string, raw []byte) (*Document, error) {
|
||||
fm, body, err := splitFrontMatter(string(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", rel, err)
|
||||
}
|
||||
var d Document
|
||||
dec := yaml.NewDecoder(strings.NewReader(fm))
|
||||
dec.KnownFields(true) // unknown front-matter fields are a schema violation
|
||||
if err := dec.Decode(&d); err != nil {
|
||||
return nil, fmt.Errorf("%s: front-matter: %w", rel, err)
|
||||
}
|
||||
d.Path = rel
|
||||
d.bodyRaw = body
|
||||
|
||||
if err := d.check(rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// BodyMarkdown returns the raw Markdown body (everything after the front-matter).
|
||||
func (d *Document) BodyMarkdown() string { return d.bodyRaw }
|
||||
|
||||
func (d *Document) check(rel string) error {
|
||||
switch {
|
||||
case d.ID == "":
|
||||
return fmt.Errorf("%s: id is required", rel)
|
||||
case d.Title == "":
|
||||
return fmt.Errorf("%s: title is required", rel)
|
||||
case d.Claim == "":
|
||||
return fmt.Errorf("%s: claim is required", rel)
|
||||
}
|
||||
if _, ok := AreaByKey(d.Area); !ok {
|
||||
return fmt.Errorf("%s: unknown area %q", rel, d.Area)
|
||||
}
|
||||
if d.Level < 1 || d.Level > 3 {
|
||||
return fmt.Errorf("%s: level must be 1, 2 or 3, got %d", rel, d.Level)
|
||||
}
|
||||
switch d.Confidence {
|
||||
case ConfidenceDelivered, ConfidenceRunning, ConfidenceInProgress, ConfidenceAbandoned, ConfidenceHistoric:
|
||||
default:
|
||||
return fmt.Errorf("%s: unknown confidence %q", rel, d.Confidence)
|
||||
}
|
||||
if d.LastVerified == "" {
|
||||
return fmt.Errorf("%s: last_verified is required", rel)
|
||||
}
|
||||
stem := strings.TrimSuffix(path.Base(rel), ".md")
|
||||
if stem != d.ID {
|
||||
return fmt.Errorf("%s: id %q must equal the file name %q", rel, d.ID, stem)
|
||||
}
|
||||
for _, a := range d.Artefacts {
|
||||
if a.URL == "" || a.Label == "" {
|
||||
return fmt.Errorf("%s: artefact %q needs both label and url", rel, a.Label)
|
||||
}
|
||||
if strings.Contains(a.URL, "://") {
|
||||
return fmt.Errorf("%s: artefact %q uses an external URL; create an in-repo stub instead", rel, a.URL)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitFrontMatter(src string) (fm, body string, err error) {
|
||||
src = strings.ReplaceAll(src, "\r\n", "\n")
|
||||
if !strings.HasPrefix(src, "---\n") {
|
||||
return "", "", fmt.Errorf("missing front-matter block on line 1")
|
||||
}
|
||||
rest := src[len("---\n"):]
|
||||
for _, delim := range []string{"\n---\n", "\n...\n"} {
|
||||
if i := strings.Index(rest, delim); i >= 0 {
|
||||
return rest[:i], rest[i+len(delim):], nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("unterminated front-matter block")
|
||||
}
|
||||
|
||||
// PageURL is the canonical URL for a document. Depth maps onto the URL path so
|
||||
// the drill-down is legible: /edge/ · /edge/edge-fleet/ · /edge/a/edge-zigbee/.
|
||||
func PageURL(d *Document) string {
|
||||
switch d.Level {
|
||||
case 1:
|
||||
return "/" + d.Area + "/"
|
||||
case 2:
|
||||
return "/" + d.Area + "/" + d.ID + "/"
|
||||
default:
|
||||
return "/" + d.Area + "/a/" + d.ID + "/"
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve maps an artefact reference onto a file, a site URL and a stage
|
||||
// fragment kind. Paths may be repository-relative ("content/edge/l3/x.md") or
|
||||
// document-relative ("./l3/x.md", "l3/x.md").
|
||||
func (s *Set) Resolve(from *Document, a Artefact) (ResolvedArtefact, error) {
|
||||
rel := a.URL
|
||||
var target string
|
||||
switch {
|
||||
case strings.HasPrefix(rel, "content/"):
|
||||
target = strings.TrimPrefix(rel, "content/")
|
||||
case strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, "../"):
|
||||
return ResolvedArtefact{}, fmt.Errorf("%s: artefact %q must not be root-absolute or escape the repo", from.Path, rel)
|
||||
default:
|
||||
target = path.Join(path.Dir(from.Path), rel)
|
||||
}
|
||||
target = path.Clean(target)
|
||||
|
||||
out := ResolvedArtefact{Artefact: a, Rel: target}
|
||||
abs := filepath.Join(s.Root, filepath.FromSlash(target))
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s: artefact %q does not resolve to a file (%s)", from.Path, a.URL, target)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return out, fmt.Errorf("%s: artefact %q resolves to a directory", from.Path, a.URL)
|
||||
}
|
||||
out.File = abs
|
||||
if strings.HasSuffix(target, ".md") {
|
||||
doc, ok := s.byRel[target]
|
||||
if !ok {
|
||||
return out, fmt.Errorf("%s: artefact %q is not a loaded document", from.Path, a.URL)
|
||||
}
|
||||
out.Target = doc
|
||||
out.Href = doc.URL
|
||||
out.Kind = stage.KindText
|
||||
return out, nil
|
||||
}
|
||||
out.Href = "/assets/" + strings.TrimPrefix(target, "assets/")
|
||||
out.Kind = KindForFile(target)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var codeExtensions = map[string]string{
|
||||
".go": "go", ".js": "javascript", ".mjs": "javascript", ".ts": "typescript",
|
||||
".nix": "nix", ".php": "php", ".as": "actionscript", ".hpp": "cpp", ".h": "cpp",
|
||||
".c": "c", ".cpp": "cpp", ".py": "python", ".sh": "bash", ".yaml": "yaml",
|
||||
".yml": "yaml", ".json": "json", ".css": "css", ".sql": "sql", ".templ": "templ",
|
||||
}
|
||||
|
||||
// KindForFile maps a file extension onto a stage fragment kind.
|
||||
func KindForFile(name string) string {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
if lang, ok := codeExtensions[ext]; ok && lang != "" {
|
||||
return stage.KindCode
|
||||
}
|
||||
switch ext {
|
||||
case ".svg":
|
||||
return stage.KindDiagram
|
||||
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif":
|
||||
return stage.KindImage
|
||||
case ".mp4", ".webm", ".mov":
|
||||
return stage.KindVideo
|
||||
case ".md":
|
||||
return stage.KindText
|
||||
}
|
||||
return stage.KindLinks
|
||||
}
|
||||
|
||||
// LanguageFor returns the chroma language for a file, if it is code.
|
||||
func LanguageFor(name string) string {
|
||||
return codeExtensions[strings.ToLower(path.Ext(name))]
|
||||
}
|
||||
|
||||
// Truncate shortens s to at most n runes, on a word boundary where possible.
|
||||
func Truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len([]rune(s)) <= n {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)[:n]
|
||||
if i := strings.LastIndex(string(r), " "); i > n/2 {
|
||||
return strings.TrimSpace(string(r)[:i]) + "…"
|
||||
}
|
||||
return strings.TrimSpace(string(r)) + "…"
|
||||
}
|
||||
|
||||
// MetricValue renders a metric value for display.
|
||||
func MetricValue(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
180
internal/content/markdown.go
Normal file
180
internal/content/markdown.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
stdhtml "html"
|
||||
"io"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
"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/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
)
|
||||
|
||||
// RendererStyle is the chroma style. Dark by default, matching the rest of
|
||||
// Sam's surfaces.
|
||||
const RendererStyle = "github-dark"
|
||||
|
||||
// Renderer turns Markdown into server-side HTML. Nothing here runs in a
|
||||
// browser: goldmark for structure and tables, chroma for code, at build time.
|
||||
type Renderer struct {
|
||||
md goldmark.Markdown
|
||||
set *Set
|
||||
}
|
||||
|
||||
// NewRenderer builds a renderer bound to a document set (needed to rewrite
|
||||
// relative .md links into site URLs).
|
||||
func NewRenderer(s *Set) *Renderer {
|
||||
r := &Renderer{set: s}
|
||||
r.md = goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
extension.Footnote,
|
||||
extension.Typographer,
|
||||
highlighting.NewHighlighting(
|
||||
highlighting.WithStyle(RendererStyle),
|
||||
highlighting.WithFormatOptions(chromahtml.WithClasses(true)),
|
||||
),
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
parser.WithASTTransformers(util.Prioritized(r, 100)),
|
||||
),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()), // trusted, in-repo Markdown
|
||||
)
|
||||
return r
|
||||
}
|
||||
|
||||
// Transform rewrites document-relative links into site URLs so the
|
||||
// pre-rendered pages work as static files. The current document is carried in
|
||||
// the parser context by Render and Highlight-free callers.
|
||||
func (r *Renderer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
current, ok := pc.Get(docKey).(*Document)
|
||||
if !ok || current == nil {
|
||||
return
|
||||
}
|
||||
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch node := n.(type) {
|
||||
case *ast.Link:
|
||||
node.Destination = r.rewrite(current, string(node.Destination), false)
|
||||
case *ast.Image:
|
||||
node.Destination = r.rewrite(current, string(node.Destination), true)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Renderer) rewrite(from *Document, dest string, image bool) []byte {
|
||||
if dest == "" || strings.Contains(dest, "://") || strings.HasPrefix(dest, "/") ||
|
||||
strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "mailto:") {
|
||||
return []byte(dest)
|
||||
}
|
||||
target := dest
|
||||
suffix := ""
|
||||
if i := strings.IndexAny(target, "#?"); i >= 0 {
|
||||
suffix = target[i:]
|
||||
target = target[:i]
|
||||
}
|
||||
rel := target
|
||||
if strings.HasPrefix(rel, "content/") {
|
||||
rel = strings.TrimPrefix(rel, "content/")
|
||||
} else {
|
||||
rel = path.Join(path.Dir(from.Path), rel)
|
||||
}
|
||||
rel = path.Clean(rel)
|
||||
if d, ok := r.set.byRel[rel]; ok {
|
||||
return []byte(d.URL + suffix)
|
||||
}
|
||||
if image || KindForFile(rel) != stage.KindLinks {
|
||||
return []byte(AssetHref(rel) + suffix)
|
||||
}
|
||||
return []byte(dest)
|
||||
}
|
||||
|
||||
// docKey carries the document being rendered through the parser context so
|
||||
// the link rewriter knows what "relative" means.
|
||||
var docKey = parser.NewContextKey()
|
||||
|
||||
// Render fills HTML and Plain on every document in the set.
|
||||
func (r *Renderer) Render() error {
|
||||
for _, d := range r.set.Docs {
|
||||
var buf bytes.Buffer
|
||||
ctx := parser.NewContext()
|
||||
ctx.Set(docKey, d)
|
||||
if err := r.md.Convert([]byte(d.BodyMarkdown()), &buf, parser.WithContext(ctx)); err != nil {
|
||||
return fmt.Errorf("%s: render Markdown: %w", d.Path, err)
|
||||
}
|
||||
d.HTML = buf.String()
|
||||
d.Plain = PlainText(d.HTML)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Highlight renders a standalone code file with chroma. Used by L3 code
|
||||
// artefacts and by the make-target that keeps the code exhibits in sync.
|
||||
func (r *Renderer) Highlight(w io.Writer, lang string, src []byte) error {
|
||||
lexer := lexers.Get(lang)
|
||||
if lexer == nil {
|
||||
lexer = lexers.Analyse(string(src))
|
||||
}
|
||||
if lexer == nil {
|
||||
_, err := fmt.Fprintf(w, "<pre class=\"chroma\"><code>%s</code></pre>", stdhtml.EscapeString(string(src)))
|
||||
return err
|
||||
}
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
it, err := lexer.Tokenise(nil, string(src))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
style := styles.Get(RendererStyle)
|
||||
if style == nil {
|
||||
return fmt.Errorf("unknown chroma style %q", RendererStyle)
|
||||
}
|
||||
formatter := chromahtml.New(chromahtml.WithClasses(true), chromahtml.WithLineNumbers(false))
|
||||
return formatter.Format(w, style, it)
|
||||
}
|
||||
|
||||
// ChromaCSS returns the stylesheet for the highlighted classes, so the built
|
||||
// site makes no colour decisions inline.
|
||||
func (r *Renderer) ChromaCSS() (string, error) {
|
||||
style := styles.Get(RendererStyle)
|
||||
if style == nil {
|
||||
return "", fmt.Errorf("unknown chroma style %q", RendererStyle)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := chromahtml.New(chromahtml.WithClasses(true)).WriteCSS(&buf, style); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// AssetHref maps a content-relative asset path onto its published URL.
|
||||
func AssetHref(rel string) string {
|
||||
return "/assets/" + strings.TrimPrefix(path.Clean(rel), "assets/")
|
||||
}
|
||||
|
||||
var tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
|
||||
// PlainText reduces rendered HTML to indexable text.
|
||||
func PlainText(h string) string {
|
||||
h = tagRe.ReplaceAllString(h, " ")
|
||||
h = stdhtml.UnescapeString(h)
|
||||
return strings.Join(strings.Fields(h), " ")
|
||||
}
|
||||
187
internal/search/search.go
Normal file
187
internal/search/search.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Package search builds the SQLite FTS5 index over the static pages.
|
||||
//
|
||||
// The index is written by the build step and opened read-only by the server, so
|
||||
// a search never touches the content tree and works with every AI service
|
||||
// stopped. The same index is the keyword half of Phase 4's hybrid retrieval:
|
||||
// its schema is documented in docs/phase-2-notes.md and must not change shape
|
||||
// without both phases agreeing.
|
||||
package search
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Doc is one indexable page.
|
||||
type Doc struct {
|
||||
ID string
|
||||
URL string
|
||||
Title string
|
||||
Area string
|
||||
Level int
|
||||
Confidence string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Result is one hit.
|
||||
type Result struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Area string `json:"area"`
|
||||
Level int `json:"level"`
|
||||
Confidence string `json:"confidence"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// Schema is the FTS5 table. Columns marked UNINDEXED are still selectable.
|
||||
const schema = `
|
||||
CREATE VIRTUAL TABLE pages USING fts5(
|
||||
id UNINDEXED,
|
||||
url UNINDEXED,
|
||||
title,
|
||||
area UNINDEXED,
|
||||
level UNINDEXED,
|
||||
confidence UNINDEXED,
|
||||
body,
|
||||
tokenize = 'porter unicode61 remove_diacritics 2'
|
||||
);`
|
||||
|
||||
// Build writes a fresh index at path. Any existing file is replaced, so the
|
||||
// index always describes exactly one build.
|
||||
func Build(path string, docs []Doc) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open index %s: %w", path, err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if _, err := db.Exec(`PRAGMA journal_mode = OFF; PRAGMA synchronous = OFF;`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return fmt.Errorf("create fts5 table: %w", err)
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO pages (id, url, title, area, level, confidence, body) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, d := range docs {
|
||||
if _, err := stmt.Exec(d.ID, d.URL, d.Title, d.Area, d.Level, d.Confidence, d.Body); err != nil {
|
||||
return fmt.Errorf("index %s: %w", d.ID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO pages (pages) VALUES ('optimize')`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Index is a read-only handle on a built index.
|
||||
type Index struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
// Open opens a built index read-only.
|
||||
func Open(path string) (*Index, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("search index %s: %w", path, err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Index{db: db, path: path}, nil
|
||||
}
|
||||
|
||||
// Close releases the handle.
|
||||
func (i *Index) Close() error { return i.db.Close() }
|
||||
|
||||
// Count returns the number of indexed pages.
|
||||
func (i *Index) Count() (int, error) {
|
||||
var n int
|
||||
err := i.db.QueryRow(`SELECT count(*) FROM pages`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Query runs a keyword search. Every term is matched as a prefix so "zigbee"
|
||||
// finds "Zigbee" and "routing" finds "routers".
|
||||
func (i *Index) Query(q string, limit int) ([]Result, error) {
|
||||
match := MatchExpression(q)
|
||||
if match == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := i.db.Query(`
|
||||
SELECT url, title, area, CAST(level AS INTEGER), confidence,
|
||||
snippet(pages, 6, '<mark>', '</mark>', '…', 18),
|
||||
bm25(pages)
|
||||
FROM pages
|
||||
WHERE pages MATCH ?
|
||||
ORDER BY bm25(pages)
|
||||
LIMIT ?`, match, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search %q: %w", q, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Result
|
||||
for rows.Next() {
|
||||
var r Result
|
||||
if err := rows.Scan(&r.URL, &r.Title, &r.Area, &r.Level, &r.Confidence, &r.Snippet, &r.Score); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MatchExpression turns free text into a safe FTS5 query: bare words only, each
|
||||
// as a prefix, all required. FTS5 operators in the input are never honoured, so
|
||||
// a visitor cannot shape the query beyond words.
|
||||
func MatchExpression(q string) string {
|
||||
terms := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool {
|
||||
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-')
|
||||
})
|
||||
if len(terms) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]string, 0, len(terms))
|
||||
for _, t := range terms {
|
||||
t = strings.Trim(t, "-")
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, `"`+t+`"*`)
|
||||
}
|
||||
return strings.Join(out, " AND ")
|
||||
}
|
||||
89
internal/search/search_test.go
Normal file
89
internal/search/search_test.go
Normal file
@@ -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, "<mark>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
602
internal/server/server.go
Normal file
602
internal/server/server.go
Normal file
@@ -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, `<div class="stage-empty"><p class="stage-empty-title">`+msg+`</p><p class="stage-hint">The <a href="/evidence/">evidence index</a> lists every fragment in this build.</p></div>`)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
371
internal/server/server_test.go
Normal file
371
internal/server/server_test.go
Normal file
@@ -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, "<!DOCTYPE html>") || strings.Contains(body, "<html") {
|
||||
t.Error("fragment response is a whole document; the panel would nest a page")
|
||||
}
|
||||
|
||||
for _, bad := range []string{"/stage/../../etc/passwd", "/stage/", "/stage/..%2f..%2fetc%2fpasswd", "/stage/does-not-exist"} {
|
||||
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
|
||||
t.Errorf("%s: status 200, want a 404 fragment", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamDriverSendsFragments is §2.4's second half: the same fragments,
|
||||
// pushed over SSE, with no layout change.
|
||||
func TestStreamDriverSendsFragments(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, header, body := get(t, ts, "/api/stage/stream?k=doc-edge-overview", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("stream status = %d, want 200", code)
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("stream content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
if got := header.Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Errorf("stream cache-control = %q, want no-store", got)
|
||||
}
|
||||
if !strings.Contains(body, "event: "+StreamEvent) {
|
||||
t.Errorf("stream has no %q event: %.200s", StreamEvent, body)
|
||||
}
|
||||
if got := strings.Count(body, "event: "+StreamEvent); got != 1 {
|
||||
t.Errorf("stream sent %d events for one key, want 1", got)
|
||||
}
|
||||
// Exactly one article per frame: the build rendered it, the server must not
|
||||
// wrap it again.
|
||||
if got := strings.Count(body, `<article class="frag`); got != 1 {
|
||||
t.Errorf("frame contains %d fragment articles, want 1", got)
|
||||
}
|
||||
if !strings.Contains(body, "data: ") {
|
||||
t.Error("stream frame has no data line")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamDefaultIsSixAreaFragments keeps the demonstration's default stream
|
||||
// honest: the six area landing fragments, in navigation order, and no more.
|
||||
func TestStreamDefaultIsSixAreaFragments(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/api/stage/stream", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("stream status = %d, want 200", code)
|
||||
}
|
||||
if got := strings.Count(body, "event: "+StreamEvent); got != 6 {
|
||||
t.Errorf("default stream has %d frames, want the six areas", got)
|
||||
}
|
||||
// Titles are HTML-escaped in the frame, so match the distinctive word.
|
||||
for _, area := range []string{"Agentic", "Platform", "Edge", "Products", "History", "About"} {
|
||||
if !strings.Contains(body, area) {
|
||||
t.Errorf("default stream is missing the %s area", area)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchWorksWithAndWithoutHTMX covers §2.5 and §2.6: the same handler
|
||||
// returns a page for a form post and a fragment for HTMX.
|
||||
func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, tc := range []struct {
|
||||
q string
|
||||
want string
|
||||
}{
|
||||
{"zigbee", "/edge/"},
|
||||
{"signage", "/products/"},
|
||||
{"routing", "/ai/"},
|
||||
} {
|
||||
code, _, body := get(t, ts, "/search?q="+tc.q, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("search %q: status %d", tc.q, code)
|
||||
continue
|
||||
}
|
||||
// templ writes a lowercase HTML5 doctype, so compare case-insensitively.
|
||||
if !strings.Contains(strings.ToLower(body), "<!doctype html>") {
|
||||
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, "<html") {
|
||||
t.Errorf("htmx search %q returned a whole page, want the results block", tc.q)
|
||||
}
|
||||
if !strings.Contains(frag, `id="search-results"`) {
|
||||
t.Errorf("htmx search %q did not return #search-results", tc.q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchRejectsOperators checks that FTS5 syntax in a visitor's input is
|
||||
// never honoured, so a query can only be words.
|
||||
func TestSearchRejectsOperators(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, q := range []string{`" OR "`, `zigbee" OR body:`, `NEAR(zigbee routing)`, `*`} {
|
||||
code, _, body := get(t, ts, "/search?q="+strings.ReplaceAll(q, " ", "+"), nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("query %q: status %d, want 200 with an honest empty/refused result", q, code)
|
||||
}
|
||||
if strings.Contains(body, "could not be run") && q == "*" {
|
||||
t.Logf("query %q reported as unparseable, which is acceptable", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownPathIsTheBuilt404Page makes sure a typo lands on the pre-rendered
|
||||
// page rather than a bare error, and that the status stays 404.
|
||||
func TestUnknownPathIsTheBuilt404Page(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/does-not-exist", nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", code)
|
||||
}
|
||||
if !strings.Contains(body, "That page is not here") {
|
||||
t.Error("404 body is not the built 404 page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticAssetsAreServedWithTheirType keeps the stylesheet, htmx and the SSE
|
||||
// extension reachable, and confirms content sniffing is refused.
|
||||
func TestStaticAssetsAreServedWithTheirType(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, tc := range []struct{ path, ctype string }{
|
||||
{"/static/site.css", "text/css"},
|
||||
{"/static/chroma.css", "text/css"},
|
||||
{"/static/htmx.min.js", "text/javascript"},
|
||||
{"/static/sse.js", "text/javascript"},
|
||||
} {
|
||||
code, header, _ := 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, tc.ctype) {
|
||||
t.Errorf("%s: content-type %q, want %q", tc.path, ct, tc.ctype)
|
||||
}
|
||||
if header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Errorf("%s: missing nosniff", tc.path)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"/static/../build.json", "/static/%2e%2e/build.json", "/assets/../dist"} {
|
||||
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
|
||||
t.Errorf("%s: status 200, want a rejection", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityHeadersAreAlwaysPresent mirrors the phase 3 edge rules at the
|
||||
// origin, so a direct request sees the same policy as one through Caddy.
|
||||
func TestSecurityHeadersAreAlwaysPresent(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, path := range []string{"/", "/search?q=zigbee", "/healthz", "/does-not-exist"} {
|
||||
_, header, _ := get(t, ts, path, nil)
|
||||
if header.Get("Content-Security-Policy") == "" {
|
||||
t.Errorf("%s: no CSP", path)
|
||||
}
|
||||
if header.Get("Referrer-Policy") == "" {
|
||||
t.Errorf("%s: no referrer policy", path)
|
||||
}
|
||||
if header.Get("X-Frame-Options") == "" {
|
||||
t.Errorf("%s: no frame options", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirstByteBudget is a coarse A2 check: on loopback, well inside the 100 ms
|
||||
// budget the specification sets for a page served over the LAN.
|
||||
func TestFirstByteBudget(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
started := time.Now()
|
||||
code, _, _ := get(t, ts, "/", nil)
|
||||
elapsed := time.Since(started)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if elapsed > 100*time.Millisecond {
|
||||
t.Errorf("first byte in %s, budget is 100ms", elapsed)
|
||||
}
|
||||
}
|
||||
188
internal/server/stream.go
Normal file
188
internal/server/stream.go
Normal file
@@ -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
|
||||
}
|
||||
318
internal/site/build.go
Normal file
318
internal/site/build.go
Normal file
@@ -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
|
||||
}
|
||||
200
internal/site/fragments.go
Normal file
200
internal/site/fragments.go
Normal file
@@ -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
|
||||
}
|
||||
509
internal/site/site.go
Normal file
509
internal/site/site.go
Normal file
@@ -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() }
|
||||
174
internal/site/site_test.go
Normal file
174
internal/site/site_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
317
internal/stage/stage.go
Normal file
317
internal/stage/stage.go
Normal file
@@ -0,0 +1,317 @@
|
||||
// Package stage defines the presentation panel as a content sink rather than a
|
||||
// page renderer.
|
||||
//
|
||||
// The contract (docs/phase-2-static-site.md §2.4):
|
||||
//
|
||||
// Stage ← driver(route) // Mode A: HTMX fragment swap, pre-rendered files
|
||||
// Stage ← driver(SSE) // Mode B: streamed fragments (Phase 5)
|
||||
//
|
||||
// Layout code renders a Binding. It never asks which driver produced it, so
|
||||
// re-pointing the panel at a stream is a one-line change at the call site in
|
||||
// Mode B's page and no change at all to Layout or Stage.
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
|
||||
// Fragment kinds the panel can display. These are the whole vocabulary: adding
|
||||
// one is a deliberate change to §2.4.
|
||||
const (
|
||||
KindText = "text"
|
||||
KindCode = "code"
|
||||
KindImage = "image"
|
||||
KindVideo = "video"
|
||||
KindDiagram = "diagram"
|
||||
KindLinks = "links"
|
||||
)
|
||||
|
||||
// PanelID is the element both drivers swap into. The templates render this id
|
||||
// and the bindings target it, so there is one name for it in the codebase.
|
||||
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
|
||||
|
||||
const (
|
||||
// ModeRoute pulls one pre-rendered fragment per interaction. Mode A.
|
||||
ModeRoute Mode = "route"
|
||||
// ModeStream receives fragments pushed over SSE. Mode B.
|
||||
ModeStream Mode = "stream"
|
||||
)
|
||||
|
||||
// Code is a highlighted code exhibit.
|
||||
type Code struct {
|
||||
Language string
|
||||
Label string
|
||||
HTML templ.Component // chroma-highlighted, or a <pre> fallback
|
||||
}
|
||||
|
||||
// Link is one row of a link-list fragment.
|
||||
type Link struct {
|
||||
Label string
|
||||
Href string
|
||||
Note string
|
||||
}
|
||||
|
||||
// Fragment is one thing the panel can show. The same struct is rendered to a
|
||||
// static file for the route driver and pushed over SSE for the stream driver.
|
||||
type Fragment struct {
|
||||
ID string
|
||||
Kind string
|
||||
Title string
|
||||
Subtitle string
|
||||
Body templ.Component // pre-rendered HTML for text fragments
|
||||
Code *Code
|
||||
Src string // image, video or diagram source
|
||||
Alt string
|
||||
Links []Link
|
||||
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.
|
||||
// Route mode contributes nothing but a marker; stream mode contributes the SSE
|
||||
// wiring. Both are rendered by the same Stage template.
|
||||
type Binding struct {
|
||||
Mode Mode
|
||||
Endpoint string // stream: the SSE endpoint
|
||||
Event string // stream: the event name to swap on
|
||||
Target string // CSS selector the driver swaps into
|
||||
Swap string // htmx swap style
|
||||
Label string // human description of the driver, shown in the empty state
|
||||
Hint string // one line telling the reader what to do
|
||||
}
|
||||
|
||||
// Attributes returns the htmx/SSE attributes for the panel. Only the stream
|
||||
// driver needs any: in route mode the trigger elements carry hx-get and
|
||||
// hx-target, because a fragment is chosen by clicking evidence.
|
||||
func (b Binding) Attributes() templ.Attributes {
|
||||
attrs := templ.Attributes{"data-stage-driver": string(b.Mode)}
|
||||
switch b.Mode {
|
||||
case ModeStream:
|
||||
attrs["hx-ext"] = "sse"
|
||||
attrs["sse-connect"] = b.Endpoint
|
||||
attrs["sse-swap"] = b.Event
|
||||
attrs["hx-swap"] = b.Swap
|
||||
attrs["aria-live"] = "polite"
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// TargetSelector is the panel a link or a stream should swap into.
|
||||
func (b Binding) TargetSelector() string {
|
||||
if b.Target == "" {
|
||||
return PanelTarget()
|
||||
}
|
||||
return b.Target
|
||||
}
|
||||
|
||||
// Driver is the whole contract between the panel and whatever feeds it.
|
||||
type Driver interface {
|
||||
Mode() Mode
|
||||
// Bind returns the binding to render on the Stage panel.
|
||||
Bind() Binding
|
||||
}
|
||||
|
||||
// RouteDriver feeds the panel from pre-rendered fragment files. Mode A.
|
||||
type RouteDriver struct {
|
||||
// BaseURL is where fragment files are served from, e.g. "/stage".
|
||||
BaseURL string
|
||||
Label string
|
||||
Hint string
|
||||
}
|
||||
|
||||
// Mode is part of the Driver contract.
|
||||
func (RouteDriver) Mode() Mode { return ModeRoute }
|
||||
|
||||
// Bind is part of the Driver contract.
|
||||
func (d RouteDriver) Bind() Binding {
|
||||
return Binding{
|
||||
Mode: ModeRoute,
|
||||
Target: PanelTarget(),
|
||||
Swap: "innerHTML",
|
||||
Label: defaultString(d.Label, "Evidence panel · driven by route"),
|
||||
Hint: defaultString(d.Hint, "Choose a claim from the evidence table; the artefact appears here."),
|
||||
}
|
||||
}
|
||||
|
||||
// FragmentURL is the pre-rendered URL for a fragment id.
|
||||
func (d RouteDriver) FragmentURL(id string) string {
|
||||
base := d.BaseURL
|
||||
if base == "" {
|
||||
base = "/stage"
|
||||
}
|
||||
return base + "/" + id
|
||||
}
|
||||
|
||||
// SSEDriver feeds the panel from an event stream. Same panel, same fragments,
|
||||
// different transport. Used by the Phase 5 console; demonstrated in Phase 2 by
|
||||
// a stream of the pre-built fragments so the plumbing is provable without AI.
|
||||
type SSEDriver struct {
|
||||
Endpoint string
|
||||
Event string
|
||||
Label string
|
||||
Hint string
|
||||
}
|
||||
|
||||
// Mode is part of the Driver contract.
|
||||
func (SSEDriver) Mode() Mode { return ModeStream }
|
||||
|
||||
// Bind is part of the Driver contract.
|
||||
func (d SSEDriver) Bind() Binding {
|
||||
event := d.Event
|
||||
if event == "" {
|
||||
event = "stage"
|
||||
}
|
||||
return Binding{
|
||||
Mode: ModeStream,
|
||||
Endpoint: d.Endpoint,
|
||||
Event: event,
|
||||
Target: PanelTarget(),
|
||||
Swap: "innerHTML",
|
||||
Label: defaultString(d.Label, "Evidence panel · driven by stream"),
|
||||
Hint: defaultString(d.Hint, "Frames arrive as they are produced and replace the panel each time."),
|
||||
}
|
||||
}
|
||||
|
||||
func defaultString(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Source produces the fragments a stream driver pushes. Phase 2 provides
|
||||
// ManifestSource; Phase 5 provides the retrieval-driven source. Neither
|
||||
// touches the panel.
|
||||
type Source interface {
|
||||
// Stream sends fragments for keys, in order, until done or ctx is done.
|
||||
// Returning an error ends the stream; the panel keeps the last fragment.
|
||||
Stream(ctx context.Context, keys []string, send func(Fragment) error) error
|
||||
}
|
||||
|
||||
// Entry is one pre-rendered fragment, as recorded at build time.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"doc"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
// File is relative to the published root, e.g. "stage/edge-fleet/1.html".
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
// Manifest is the build-time index of fragments. The server uses it to stream
|
||||
// fragments that were rendered once, and the route driver serves the same
|
||||
// files directly.
|
||||
type Manifest struct {
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
// ByID looks an entry up.
|
||||
func (m *Manifest) ByID(id string) (Entry, bool) {
|
||||
for _, e := range m.Entries {
|
||||
if e.ID == id {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return Entry{}, false
|
||||
}
|
||||
|
||||
// ForDoc returns the entries belonging to a document, oldest first.
|
||||
func (m *Manifest) ForDoc(docID string) []Entry {
|
||||
var out []Entry
|
||||
for _, e := range m.Entries {
|
||||
if e.DocID == docID {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Marshal encodes the manifest for the build to write.
|
||||
func (m *Manifest) Marshal() ([]byte, error) { return json.MarshalIndent(m, "", " ") }
|
||||
|
||||
// LoadManifest parses a manifest written by the build.
|
||||
func LoadManifest(b []byte) (*Manifest, error) {
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, fmt.Errorf("stage manifest: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ManifestSource streams fragments that were pre-rendered at build time. It
|
||||
// exists to prove the stream path end to end without a model in the loop; the
|
||||
// Phase 5 source implements the same interface with retrieval behind it.
|
||||
type ManifestSource struct {
|
||||
manifest *Manifest
|
||||
// HTML returns the pre-rendered markup for an entry.
|
||||
HTML func(Entry) (string, error)
|
||||
// Frame is an optional hook called as each fragment is sent, for pacing.
|
||||
Frame func(index int)
|
||||
}
|
||||
|
||||
// NewManifestSource wires a manifest and a reader together.
|
||||
func NewManifestSource(m *Manifest, read func(Entry) (string, error), frame func(int)) *ManifestSource {
|
||||
return &ManifestSource{manifest: m, HTML: read, Frame: frame}
|
||||
}
|
||||
|
||||
// Stream is part of the Source contract.
|
||||
func (s *ManifestSource) Stream(ctx context.Context, keys []string, send func(Fragment) error) error {
|
||||
entries := make([]Entry, 0, len(keys))
|
||||
if len(keys) == 0 {
|
||||
entries = append(entries, s.manifest.Entries...)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if e, ok := s.manifest.ByID(k); ok {
|
||||
entries = append(entries, e)
|
||||
continue
|
||||
}
|
||||
// A document id expands to that document's fragments.
|
||||
entries = append(entries, s.manifest.ForDoc(k)...)
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].ID < entries[j].ID })
|
||||
|
||||
for i, e := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
html, err := s.HTML(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Frame != nil {
|
||||
s.Frame(i)
|
||||
}
|
||||
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
|
||||
}
|
||||
148
internal/stage/stage_test.go
Normal file
148
internal/stage/stage_test.go
Normal file
@@ -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 "<article>" + e.ID + "</article>", 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 "<article></article>", 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))
|
||||
}
|
||||
}
|
||||
12
internal/web/assets.go
Normal file
12
internal/web/assets.go
Normal file
@@ -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
|
||||
1
internal/web/static/htmx.min.js
vendored
Normal file
1
internal/web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1360
internal/web/static/site.css
Normal file
1360
internal/web/static/site.css
Normal file
File diff suppressed because it is too large
Load Diff
290
internal/web/static/sse.js
Normal file
290
internal/web/static/sse.js
Normal file
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
Server Sent Events Extension
|
||||
============================
|
||||
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||
|
||||
*/
|
||||
|
||||
(function() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('sse', {
|
||||
|
||||
/**
|
||||
* Init saves the provided reference to the internal HTMX API.
|
||||
*
|
||||
* @param {import("../htmx").HtmxInternalApi} api
|
||||
* @returns void
|
||||
*/
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef
|
||||
|
||||
// set a function in the public API for creating new EventSource objects
|
||||
if (htmx.createEventSource == undefined) {
|
||||
htmx.createEventSource = createEventSource
|
||||
}
|
||||
},
|
||||
|
||||
getSelectors: function() {
|
||||
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
* @returns void
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
var parent = evt.target || evt.detail.elt
|
||||
switch (name) {
|
||||
case 'htmx:beforeCleanupElement':
|
||||
var internalData = api.getInternalData(parent)
|
||||
// Try to remove remove an EventSource when elements are removed
|
||||
var source = internalData.sseEventSource
|
||||
if (source) {
|
||||
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeReplaced',
|
||||
})
|
||||
internalData.sseEventSource.close()
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
// Try to create EventSources when elements are processed
|
||||
case 'htmx:afterProcessNode':
|
||||
ensureEventSourceOnElement(parent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/// ////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
/// ////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* createEventSource is the default method for creating new EventSource objects.
|
||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns EventSource
|
||||
*/
|
||||
function createEventSource(url) {
|
||||
return new EventSource(url, { withCredentials: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* registerSSE looks for attributes that can contain sse events, right
|
||||
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||
* the closest event source
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function registerSSE(elt) {
|
||||
// Add message handlers for every `sse-swap` attribute
|
||||
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||
var sseEventNames = sseSwapAttr.split(',')
|
||||
|
||||
for (var i = 0; i < sseEventNames.length; i++) {
|
||||
const sseEventName = sseEventNames[i].trim()
|
||||
const listener = function(event) {
|
||||
// If the source is missing then close SSE
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the body no longer contains the element, remove the listener
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(sseEventName, listener)
|
||||
return
|
||||
}
|
||||
|
||||
// swap the response into the DOM and trigger a notification
|
||||
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||
return
|
||||
}
|
||||
swap(elt, event.data)
|
||||
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(sseEventName, listener)
|
||||
}
|
||||
}
|
||||
|
||||
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||
triggerSpecs.forEach(function(ts) {
|
||||
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||
return
|
||||
}
|
||||
|
||||
var listener = function (event) {
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||
}
|
||||
// Trigger events to be handled by the rest of htmx
|
||||
htmx.trigger(elt, ts.trigger, event)
|
||||
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(ts.trigger.slice(4), listener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||
* is created and stored in the element's internalData.
|
||||
* @param {HTMLElement} elt
|
||||
* @param {number} retryCount
|
||||
* @returns {EventSource | null}
|
||||
*/
|
||||
function ensureEventSourceOnElement(elt, retryCount) {
|
||||
if (elt == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// handle extension source creation attribute
|
||||
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||
if (sseURL == null) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureEventSource(elt, sseURL, retryCount)
|
||||
}
|
||||
|
||||
registerSSE(elt)
|
||||
}
|
||||
|
||||
function ensureEventSource(elt, url, retryCount) {
|
||||
var source = htmx.createEventSource(url)
|
||||
|
||||
source.onerror = function(err) {
|
||||
// Log an error event
|
||||
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||
|
||||
// If parent no longer exists in the document, then clean up this EventSource
|
||||
if (maybeCloseSSESource(elt)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to reconnect the EventSource
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
retryCount = retryCount || 0
|
||||
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||
var timeout = retryCount * 500
|
||||
window.setTimeout(function() {
|
||||
ensureEventSourceOnElement(elt, retryCount)
|
||||
}, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
source.onopen = function(evt) {
|
||||
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||
|
||||
if (retryCount && retryCount > 0) {
|
||||
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||
for (let i = 0; i < childrenToFix.length; i++) {
|
||||
registerSSE(childrenToFix[i])
|
||||
}
|
||||
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||
retryCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
api.getInternalData(elt).sseEventSource = source
|
||||
|
||||
|
||||
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||
if (closeAttribute) {
|
||||
// close eventsource when this message is received
|
||||
source.addEventListener(closeAttribute, function() {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'message',
|
||||
})
|
||||
source.close()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseSSESource confirms that the parent element still exists.
|
||||
* If not, then any associated SSE source is closed and the function returns true.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @returns boolean
|
||||
*/
|
||||
function maybeCloseSSESource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
var source = api.getInternalData(elt).sseEventSource
|
||||
if (source != undefined) {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeMissing',
|
||||
})
|
||||
source.close()
|
||||
// source = null
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} content
|
||||
*/
|
||||
function swap(elt, content) {
|
||||
api.withExtensions(elt, function(extension) {
|
||||
content = extension.transformResponse(content, null, elt)
|
||||
})
|
||||
|
||||
var swapSpec = api.getSwapSpecification(elt)
|
||||
var target = api.getTarget(elt)
|
||||
api.swap(target, content, swapSpec)
|
||||
}
|
||||
|
||||
|
||||
function hasEventSource(node) {
|
||||
return api.getInternalData(node).sseEventSource != null
|
||||
}
|
||||
})()
|
||||
343
internal/web/templates/components.templ
Normal file
343
internal/web/templates/components.templ
Normal file
@@ -0,0 +1,343 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
)
|
||||
|
||||
// Layout is the whole document. It renders the stage binding it is handed and
|
||||
// never inspects which driver produced it.
|
||||
templ Layout(v LayoutView) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-AU">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{ v.Title }</title>
|
||||
<meta name="description" content={ v.Description }/>
|
||||
<meta name="color-scheme" content="dark"/>
|
||||
<link rel="stylesheet" href="/static/site.css"/>
|
||||
<link rel="stylesheet" href="/static/chroma.css"/>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
if v.Stage.Mode == stage.ModeStream {
|
||||
<script src="/static/sse.js" defer></script>
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
@Nav(v.Nav, v.Area)
|
||||
<div class="shell">
|
||||
<main id="main" class="main">
|
||||
@Breadcrumbs(v.Crumbs)
|
||||
{ children... }
|
||||
</main>
|
||||
@Stage(StageView{Binding: v.Stage})
|
||||
</div>
|
||||
@Footer(v)
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// Nav is the six-area navigation: a sidebar on desktop, a <details> disclosure
|
||||
// below 900 px so it still works with JavaScript disabled.
|
||||
templ Nav(items []NavItem, current string) {
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">SR</span>
|
||||
<span class="brand-text">
|
||||
Sam Rolfe
|
||||
<span class="brand-sub">AI · Platform · Edge</span>
|
||||
</span>
|
||||
</a>
|
||||
@SearchForm("", "nav-search")
|
||||
<details class="nav-disclosure">
|
||||
<summary class="nav-toggle" aria-label="Sections">Sections</summary>
|
||||
<nav class="nav" aria-label="Sections">
|
||||
@navLinks(items, current)
|
||||
@evidenceLink(current)
|
||||
</nav>
|
||||
</details>
|
||||
<nav class="nav nav-rail" aria-label="Sections">
|
||||
@navLinks(items, current)
|
||||
@evidenceLink(current)
|
||||
</nav>
|
||||
</header>
|
||||
}
|
||||
|
||||
templ navLinks(items []NavItem, current string) {
|
||||
for _, it := range items {
|
||||
<a href={ templ.SafeURL(it.Href) } class={ navClass(it) } aria-current={ currentAttr(it) }>
|
||||
<span class="nav-gutter" aria-hidden="true">{ it.Gutter }</span>
|
||||
<span class="nav-title">{ it.Title }</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
templ evidenceLink(current string) {
|
||||
<a href="/evidence/" class={ navClassFor(current == "evidence") }>
|
||||
<span class="nav-gutter" aria-hidden="true">EV</span>
|
||||
<span class="nav-title">Evidence index</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
// Breadcrumbs shows where the reader is, and is a real link trail.
|
||||
templ Breadcrumbs(crumbs []Crumb) {
|
||||
if len(crumbs) > 0 {
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
for i, c := range crumbs {
|
||||
<li>
|
||||
if i == len(crumbs)-1 {
|
||||
<span aria-current="page">{ c.Label }</span>
|
||||
} else {
|
||||
<a href={ templ.SafeURL(c.Href) }>{ c.Label }</a>
|
||||
<span class="crumb-sep" aria-hidden="true">/</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
|
||||
// SearchForm is a GET form: it works with JavaScript disabled and is upgraded by
|
||||
// HTMX when scripts are available.
|
||||
templ SearchForm(q string, class string) {
|
||||
<form class={ "search", class } action="/search" method="get" role="search"
|
||||
hx-get="/search" hx-target="#search-results" hx-swap="innerHTML" hx-push-url="true">
|
||||
<label class="visually-hidden" for={ "q-" + class }>Search the site</label>
|
||||
<input id={ "q-" + class } type="search" name="q" value={ q } placeholder="Search — try “zigbee”, “signage”, “routing”" autocomplete="off"/>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
// Stage is the presentation panel. Everything about how it is fed comes from
|
||||
// the binding: route mode swaps in pre-rendered fragments, stream mode listens
|
||||
// on an event stream. The markup is identical either way.
|
||||
templ Stage(v StageView) {
|
||||
<aside id="stage" class="stage" aria-label="Presentation panel">
|
||||
<header class="stage-head">
|
||||
<h2 class="stage-title">Presentation panel</h2>
|
||||
<p class="stage-driver" data-driver={ string(v.Binding.Mode) }>{ driverLabel(v.Binding.Mode) }</p>
|
||||
</header>
|
||||
<div id={ stage.PanelID } class="stage-body" { v.Binding.Attributes()... }>
|
||||
if v.Fragment != nil {
|
||||
@FragmentView(*v.Fragment)
|
||||
} else {
|
||||
<div class="stage-empty">
|
||||
<p class="stage-empty-title">{ v.Binding.Label }</p>
|
||||
<p class="stage-hint">{ v.Binding.Hint }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<footer class="stage-foot">
|
||||
<a href="/evidence/">Every artefact</a>
|
||||
<a href="/stream/">Streaming panel</a>
|
||||
</footer>
|
||||
</aside>
|
||||
}
|
||||
|
||||
// FragmentView renders one stage fragment. This is the only place that knows
|
||||
// what a fragment kind looks like, and it is shared by the route driver (at
|
||||
// build time) and the stream driver (at request time).
|
||||
templ FragmentView(f stage.Fragment) {
|
||||
<article class={ "frag frag-" + f.Kind }>
|
||||
<header class="frag-head">
|
||||
<p class="frag-kind">{ f.Kind }</p>
|
||||
<h3 class="frag-title">{ f.Title }</h3>
|
||||
if f.Subtitle != "" {
|
||||
<p class="frag-sub">{ f.Subtitle }</p>
|
||||
}
|
||||
</header>
|
||||
switch f.Kind {
|
||||
case stage.KindText:
|
||||
<div class="frag-text">@f.Body</div>
|
||||
case stage.KindCode:
|
||||
if f.Code != nil {
|
||||
<figure class="code">
|
||||
<figcaption>{ f.Code.Label }<span class="code-lang">{ f.Code.Language }</span></figcaption>
|
||||
@f.Code.HTML
|
||||
</figure>
|
||||
}
|
||||
case stage.KindDiagram, stage.KindImage:
|
||||
<figure class="media">
|
||||
<img src={ templ.SafeURL(f.Src) } alt={ f.Alt } loading="lazy"/>
|
||||
if f.Subtitle != "" {
|
||||
<figcaption>{ f.Subtitle }</figcaption>
|
||||
}
|
||||
</figure>
|
||||
case stage.KindVideo:
|
||||
<figure class="media">
|
||||
<video controls preload="metadata" src={ templ.SafeURL(f.Src) }></video>
|
||||
if f.Subtitle != "" {
|
||||
<figcaption>{ f.Subtitle }</figcaption>
|
||||
}
|
||||
</figure>
|
||||
case stage.KindLinks:
|
||||
if len(f.Links) > 0 {
|
||||
<ul class="frag-links">
|
||||
for _, l := range f.Links {
|
||||
<li>
|
||||
<a href={ templ.SafeURL(l.Href) }>{ l.Label }</a>
|
||||
if l.Note != "" {
|
||||
<span class="frag-note">{ l.Note }</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
<footer class="frag-foot">
|
||||
if f.Confidence != "" {
|
||||
<span class={ ConfidenceClass(f.Confidence) }>{ f.Confidence }</span>
|
||||
}
|
||||
if f.DocURL != "" {
|
||||
<a class="frag-doc" href={ templ.SafeURL(f.DocURL) }>{ f.DocTitle }</a>
|
||||
}
|
||||
</footer>
|
||||
</article>
|
||||
}
|
||||
|
||||
// EvidenceTable lists artefacts. Rows with HXGet load the artefact into the
|
||||
// stage panel; every row is also a plain link, so this works with JavaScript
|
||||
// disabled.
|
||||
templ EvidenceTable(rows []EvidenceRow) {
|
||||
<table class="evidence">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Evidence</th>
|
||||
<th scope="col">Kind</th>
|
||||
<th scope="col">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, r := range rows {
|
||||
<tr>
|
||||
<td>
|
||||
if r.HXGet != "" {
|
||||
<a href={ templ.SafeURL(r.Href) } hx-get={ r.HXGet } hx-target={ stage.PanelTarget() } hx-swap="innerHTML" hx-push-url="false">{ r.Label }</a>
|
||||
} else {
|
||||
<a href={ templ.SafeURL(r.Href) }>{ r.Label }</a>
|
||||
}
|
||||
</td>
|
||||
<td><span class="kind">{ r.Kind }</span></td>
|
||||
<td>
|
||||
if r.Confidence != "" {
|
||||
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
// PillarCard is one landing-page pillar: the claim, the numbers, and the way in.
|
||||
templ PillarCard(c PillarCardView) {
|
||||
<article class="pillar">
|
||||
<p class="pillar-kicker">
|
||||
<span class="pillar-gutter" aria-hidden="true">{ c.Area.Gutter }</span>
|
||||
if c.Area.Role != "" {
|
||||
<span>{ c.Area.Role }</span>
|
||||
}
|
||||
</p>
|
||||
<h2 class="pillar-title">
|
||||
<a href={ templ.SafeURL(c.Href) }>{ c.Title }</a>
|
||||
</h2>
|
||||
<p class="pillar-claim">{ c.Claim }</p>
|
||||
if len(c.Metrics) > 0 {
|
||||
<ul class="metrics">
|
||||
for _, m := range c.Metrics {
|
||||
<li class="chip">
|
||||
<strong>{ m.Value }</strong>
|
||||
<span>{ m.Label }</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
if len(c.Tech) > 0 {
|
||||
<ul class="tech" aria-label="Technology">
|
||||
for _, t := range c.Tech {
|
||||
<li>{ t }</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<p class="pillar-foot">
|
||||
<a class="pillar-go" href={ templ.SafeURL(c.Href) }>
|
||||
if c.Level == 1 {
|
||||
<span>Start here</span>
|
||||
} else {
|
||||
<span>Read the capability page</span>
|
||||
}
|
||||
</a>
|
||||
<span class="pillar-count">{ strconv.Itoa(c.Pages) } pages</span>
|
||||
<span class={ ConfidenceClass(c.Confidence) }>{ c.Confidence }</span>
|
||||
</p>
|
||||
</article>
|
||||
}
|
||||
|
||||
// Timeline renders the history area as a timeline.
|
||||
templ Timeline(items []TimelineItem) {
|
||||
<ol class="timeline">
|
||||
for _, it := range items {
|
||||
<li class="timeline-item">
|
||||
<p class="timeline-period">{ it.Period }</p>
|
||||
<div class="timeline-body">
|
||||
<h3><a href={ templ.SafeURL(it.Href) }>{ it.Title }</a></h3>
|
||||
<p>{ it.Claim }</p>
|
||||
<span class={ ConfidenceClass(it.Confidence) }>{ it.Confidence }</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
|
||||
// CodeBlock renders an already-highlighted code exhibit.
|
||||
templ CodeBlock(c stage.Code) {
|
||||
<figure class="code">
|
||||
<figcaption>{ c.Label }<span class="code-lang">{ c.Language }</span></figcaption>
|
||||
@c.HTML
|
||||
</figure>
|
||||
}
|
||||
|
||||
// Footer closes the page with the honesty notes a recruiter needs.
|
||||
templ Footer(v LayoutView) {
|
||||
<footer class="footer">
|
||||
<p class="footer-line">Twenty-five years shipping software · Melbourne · remote · hybrid · office · travel</p>
|
||||
<p class="footer-note">
|
||||
Status labels are literal: <span class="conf conf-running">running</span> and
|
||||
<span class="conf conf-delivered">delivered</span> are claims; <span class="conf conf-in-progress">in-progress</span> and
|
||||
<span class="conf conf-abandoned">abandoned</span> are stated as such.
|
||||
</p>
|
||||
<p class="footer-links">
|
||||
<a href="/evidence/">Evidence index</a>
|
||||
<a href="/search">Search</a>
|
||||
<a href="/stream/">Streaming panel</a>
|
||||
<span class="footer-copy">© { strconv.Itoa(v.Year) } Sam Rolfe</span>
|
||||
</p>
|
||||
</footer>
|
||||
}
|
||||
|
||||
func navClass(it NavItem) string { return navClassFor(it.Current) }
|
||||
|
||||
func navClassFor(current bool) string {
|
||||
if current {
|
||||
return "nav-link nav-current"
|
||||
}
|
||||
return "nav-link"
|
||||
}
|
||||
|
||||
func currentAttr(it NavItem) string {
|
||||
if it.Current {
|
||||
return "page"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func driverLabel(m stage.Mode) string {
|
||||
if m == stage.ModeStream {
|
||||
return "stream driver · SSE"
|
||||
}
|
||||
return "route driver · pre-rendered"
|
||||
}
|
||||
254
internal/web/templates/pages.templ
Normal file
254
internal/web/templates/pages.templ
Normal file
@@ -0,0 +1,254 @@
|
||||
package templates
|
||||
|
||||
import "strconv"
|
||||
|
||||
// HomePage is the landing page: the 90-second read.
|
||||
templ HomePage(v HomeView) {
|
||||
@Layout(v.Layout) {
|
||||
<section class="hero">
|
||||
<p class="hero-kicker">Melbourne · twenty-five years · AI · platform · edge</p>
|
||||
<h1 class="hero-title">{ v.Positioning }</h1>
|
||||
<p class="hero-support">{ v.Support }</p>
|
||||
<p class="hero-facts">
|
||||
<span>{ strconv.Itoa(v.Areas) } areas</span>
|
||||
<span>{ strconv.Itoa(v.Pages) } pages</span>
|
||||
<span>{ strconv.Itoa(v.Artifacts) } linked artefacts</span>
|
||||
<span>no page needs a model to render</span>
|
||||
</p>
|
||||
@SearchForm("", "hero-search")
|
||||
</section>
|
||||
|
||||
<section class="pillars" aria-label="Six areas">
|
||||
for _, c := range v.Cards {
|
||||
@PillarCard(c)
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="honesty" aria-labelledby="how-to-read">
|
||||
<h2 id="how-to-read">How to read this site</h2>
|
||||
<ul>
|
||||
<li><strong>Level 1</strong> is one screen per area: the claim, the numbers, the way in.</li>
|
||||
<li><strong>Level 2</strong> is what is actually run, with an evidence table and the trade-offs.</li>
|
||||
<li><strong>Level 3</strong> is the artefact: code, diagram, screenshot, log.</li>
|
||||
<li>Every claim carries a <strong>status</strong>, and finished, running, in-progress and abandoned are all shown as they are.</li>
|
||||
<li>Every artefact is at most three clicks from here, and the <a href="/evidence/">evidence index</a> lists all of them on one page.</li>
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
// DocPage renders an L1, L2 or L3 document.
|
||||
templ DocPage(v DocView) {
|
||||
@Layout(v.Layout) {
|
||||
<article class="doc">
|
||||
<header class="doc-head">
|
||||
<p class="doc-kicker">{ v.AreaTitle } · { v.LevelLabel }</p>
|
||||
<h1 class="doc-title">{ v.Doc.Title }</h1>
|
||||
<p class="doc-claim">{ v.Doc.Claim }</p>
|
||||
<div class="doc-chips">
|
||||
<span class={ ConfidenceClass(v.Doc.Confidence) }>{ v.Doc.Confidence }</span>
|
||||
if v.Doc.Period != "" {
|
||||
<span class="chip chip-plain">{ v.Doc.Period }</span>
|
||||
}
|
||||
for _, m := range v.Metrics {
|
||||
<span class="chip">
|
||||
<strong>{ m.Value }</strong>
|
||||
<span>{ m.Label }</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
if len(v.Tech) > 0 {
|
||||
<ul class="tech" aria-label="Technology">
|
||||
for _, t := range v.Tech {
|
||||
<li>{ t }</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="prose">
|
||||
@v.Body
|
||||
</div>
|
||||
|
||||
if len(v.Evidence) > 0 {
|
||||
<section class="doc-evidence" aria-labelledby="evidence-heading">
|
||||
<h2 id="evidence-heading">Evidence for this page</h2>
|
||||
<p class="section-note">Select a row to load the artefact into the presentation panel. Each row is also a link, so this works without JavaScript.</p>
|
||||
@EvidenceTable(evidenceRows(v.Evidence))
|
||||
</section>
|
||||
}
|
||||
|
||||
if len(v.Timeline) > 0 {
|
||||
<section class="doc-timeline" aria-labelledby="timeline-heading">
|
||||
<h2 id="timeline-heading">In time order</h2>
|
||||
@Timeline(v.Timeline)
|
||||
</section>
|
||||
}
|
||||
|
||||
if len(v.SubPages) > 0 {
|
||||
<section class="doc-sub" aria-labelledby="sub-heading">
|
||||
<h2 id="sub-heading">{ subHeading(v.Doc.Level) }</h2>
|
||||
<ul class="drill-list">
|
||||
for _, s := range v.SubPages {
|
||||
<li>
|
||||
<a href={ templ.SafeURL(s.Href) }>{ s.Label }</a>
|
||||
<span class="drill-kind">{ s.Kind }</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
if len(v.Backlinks) > 0 {
|
||||
<section class="doc-back" aria-labelledby="back-heading">
|
||||
<h2 id="back-heading">Referenced from</h2>
|
||||
<ul class="drill-list">
|
||||
for _, b := range v.Backlinks {
|
||||
<li><a href={ templ.SafeURL(b.Href) }>{ b.Label }</a></li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<footer class="doc-foot">
|
||||
<p>Status <span class={ ConfidenceClass(v.Doc.Confidence) }>{ v.Doc.Confidence }</span> — { ConfidenceNote(v.Doc.Confidence) }</p>
|
||||
<p>Last verified { v.Doc.LastVerified } · id <code>{ v.Doc.ID }</code> · { v.Doc.Path }</p>
|
||||
</footer>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// EvidencePage is one page listing every artefact, grouped by area. It is the
|
||||
// shortest path from the landing page to any proof.
|
||||
templ EvidencePage(v EvidenceView) {
|
||||
@Layout(v.Layout) {
|
||||
<article class="doc">
|
||||
<header class="doc-head">
|
||||
<p class="doc-kicker">Index</p>
|
||||
<h1>Evidence index</h1>
|
||||
<p class="doc-claim">Every artefact this site claims, on one page. { strconv.Itoa(v.Total) } links, each one a file in the repository.</p>
|
||||
</header>
|
||||
for _, g := range v.Groups {
|
||||
<section class="evidence-group">
|
||||
<h2><a href={ templ.SafeURL(g.Href) }>{ g.AreaTitle }</a></h2>
|
||||
@EvidenceTable(g.Rows)
|
||||
</section>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// SearchPage is the keyword search. The results block is swapped by HTMX on the
|
||||
// same page; with JavaScript off the form simply reloads the page.
|
||||
templ SearchPage(v SearchView) {
|
||||
@Layout(v.Layout) {
|
||||
<article class="doc">
|
||||
<header class="doc-head">
|
||||
<p class="doc-kicker">Keyword search</p>
|
||||
<h1>Search { strconv.Itoa(v.Indexed) } pages</h1>
|
||||
<p class="doc-claim">Full-text search over page titles and body text. Served from a local index — no model, no network call.</p>
|
||||
</header>
|
||||
@SearchForm(v.Query, "page-search")
|
||||
@SearchResults(v)
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// SearchResults is swapped into #search-results by HTMX.
|
||||
templ SearchResults(v SearchView) {
|
||||
<div id="search-results" aria-live="polite">
|
||||
if v.Query == "" {
|
||||
<p class="section-note">Try “zigbee”, “signage”, “routing”, “backups” or “guardrails”.</p>
|
||||
} else if v.Failed {
|
||||
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
|
||||
} else if len(v.Results) == 0 {
|
||||
<p class="search-empty">No page matches <q>{ v.Query }</q>. The <a href="/evidence/">evidence index</a> lists everything by area.</p>
|
||||
} else {
|
||||
<p class="search-count">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
|
||||
<ol class="results">
|
||||
for _, r := range v.Results {
|
||||
<li class="result">
|
||||
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
|
||||
<p class="result-meta">
|
||||
<span class="kind">{ r.Area }</span>
|
||||
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
|
||||
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
|
||||
</p>
|
||||
<p class="result-snippet">@SafeSnippet(r.Snippet)</p>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// StreamPage proves the panel can be re-pointed at a stream. The layout and the
|
||||
// panel markup are the ones every other page uses; only the binding differs.
|
||||
templ StreamPage(v StreamView) {
|
||||
@Layout(v.Layout) {
|
||||
<article class="doc">
|
||||
<header class="doc-head">
|
||||
<p class="doc-kicker">Design note · not a product feature</p>
|
||||
<h1>The panel takes content from a driver</h1>
|
||||
<p class="doc-claim">This page renders the same layout and the same panel as every other page. Its panel is bound to an event stream instead of pre-rendered files, and no layout code changed.</p>
|
||||
</header>
|
||||
<div class="prose">
|
||||
<p>The presentation panel is a content sink. In Mode A it takes pre-rendered fragments over
|
||||
HTMX: you click a row in an evidence table and one file is swapped in. On this page the same
|
||||
panel is bound to <code>/api/stage/stream</code> and receives { strconv.Itoa(v.Frames) } fragments
|
||||
over server-sent events, one after another.</p>
|
||||
<p>Fragments are pushed, not requested. Nothing about the panel — its markup, its fragment
|
||||
view, its empty state — knows the difference. That is the point: the Phase 5 console
|
||||
re-points this panel at retrieval output and the layout does not change.</p>
|
||||
<p class="section-note">This demonstration streams the artefacts already built for the site, with
|
||||
no model in the loop. It exists to prove the transport, not to pretend an AI is answering.</p>
|
||||
</div>
|
||||
if len(v.Fragment) > 0 {
|
||||
<section class="doc-sub">
|
||||
<h2>Fragments in this stream</h2>
|
||||
<ul class="drill-list">
|
||||
for _, f := range v.Fragment {
|
||||
<li>
|
||||
<a href={ templ.SafeURL(f.Href) } hx-get={ f.HXGet } hx-target="#stage-content" hx-swap="innerHTML">{ f.Label }</a>
|
||||
<span class="drill-kind">{ f.Kind }</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// NotFoundPage is the 404.
|
||||
templ NotFoundPage(v NotFoundView) {
|
||||
@Layout(v.Layout) {
|
||||
<article class="doc">
|
||||
<header class="doc-head">
|
||||
<p class="doc-kicker">404</p>
|
||||
<h1>That page is not here</h1>
|
||||
<p class="doc-claim">The link may be from an older build. The six areas and the evidence index are the reliable ways in.</p>
|
||||
</header>
|
||||
<ul class="drill-list">
|
||||
<li><a href="/">Landing page</a></li>
|
||||
<li><a href="/evidence/">Evidence index</a></li>
|
||||
<li><a href="/search">Search</a></li>
|
||||
</ul>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
func evidenceRows(links []LinkView) []EvidenceRow {
|
||||
rows := make([]EvidenceRow, 0, len(links))
|
||||
for _, l := range links {
|
||||
rows = append(rows, EvidenceRow{Label: l.Label, Href: l.Href, HXGet: l.HXGet, Kind: l.Kind, Confidence: l.Confidence})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func subHeading(level int) string {
|
||||
if level == 1 {
|
||||
return "Drill-downs in this area"
|
||||
}
|
||||
return "Artefacts behind this page"
|
||||
}
|
||||
229
internal/web/templates/view.go
Normal file
229
internal/web/templates/view.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// 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 (
|
||||
stdhtml "html"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// NavItem is one left-nav entry.
|
||||
type NavItem struct {
|
||||
Title string
|
||||
Href string
|
||||
Gutter string
|
||||
Current bool
|
||||
}
|
||||
|
||||
// Crumb is one breadcrumb.
|
||||
type Crumb struct {
|
||||
Label string
|
||||
Href string
|
||||
}
|
||||
|
||||
// LayoutView is everything the chrome needs.
|
||||
type LayoutView struct {
|
||||
Title string
|
||||
Description string
|
||||
Area string
|
||||
Nav []NavItem
|
||||
Crumbs []Crumb
|
||||
Stage stage.Binding
|
||||
SearchQuery string
|
||||
Year int
|
||||
SkipAI bool // true on Mode A pages, which never need a model
|
||||
}
|
||||
|
||||
// StageView wraps the binding so the Stage component has one argument.
|
||||
type StageView struct {
|
||||
Binding stage.Binding
|
||||
// Fragment, when set, is rendered into the panel at build time.
|
||||
Fragment *stage.Fragment
|
||||
}
|
||||
|
||||
// MetricView is one approved chip.
|
||||
type MetricView struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
// LinkView is a drill-down link. HXGet makes it a stage trigger; Href keeps it
|
||||
// a working link when JavaScript is off.
|
||||
type LinkView struct {
|
||||
Label string
|
||||
Href string
|
||||
HXGet string
|
||||
Kind string
|
||||
Note string
|
||||
Confidence string
|
||||
}
|
||||
|
||||
// EvidenceRow is one row of an EvidenceTable.
|
||||
type EvidenceRow struct {
|
||||
Label string
|
||||
Href string
|
||||
HXGet string
|
||||
Kind string
|
||||
Confidence string
|
||||
}
|
||||
|
||||
// EvidenceGroup groups rows by area on the evidence index.
|
||||
type EvidenceGroup struct {
|
||||
AreaTitle string
|
||||
Href string
|
||||
Rows []EvidenceRow
|
||||
}
|
||||
|
||||
// TimelineItem is one row of a timeline.
|
||||
type TimelineItem struct {
|
||||
Period string
|
||||
Title string
|
||||
Href string
|
||||
Confidence string
|
||||
Claim string
|
||||
}
|
||||
|
||||
// PillarCardView is one of the six landing cards.
|
||||
type PillarCardView struct {
|
||||
Area content.Area
|
||||
Title string
|
||||
Claim string
|
||||
Href string
|
||||
Metrics []MetricView
|
||||
Tech []string
|
||||
Confidence string
|
||||
Level int
|
||||
Pages int
|
||||
}
|
||||
|
||||
// Layout of each page kind.
|
||||
type (
|
||||
// HomeView is the landing page.
|
||||
HomeView struct {
|
||||
Layout LayoutView
|
||||
Positioning string
|
||||
Support string
|
||||
Location string
|
||||
Cards []PillarCardView
|
||||
Artifacts int
|
||||
Pages int
|
||||
Areas int
|
||||
}
|
||||
|
||||
// DocView is an L1, L2 or L3 content page.
|
||||
DocView struct {
|
||||
Layout LayoutView
|
||||
Doc *content.Document
|
||||
AreaTitle string
|
||||
Role string
|
||||
Body templ.Component
|
||||
Metrics []MetricView
|
||||
Tech []string
|
||||
Evidence []LinkView
|
||||
Backlinks []LinkView
|
||||
SubPages []LinkView
|
||||
Timeline []TimelineItem
|
||||
LevelLabel string
|
||||
}
|
||||
|
||||
// EvidenceView is the index of every artefact.
|
||||
EvidenceView struct {
|
||||
Layout LayoutView
|
||||
Groups []EvidenceGroup
|
||||
Total int
|
||||
}
|
||||
|
||||
// SearchView is the keyword search page.
|
||||
SearchView struct {
|
||||
Layout LayoutView
|
||||
Query string
|
||||
Results []search.Result
|
||||
Total int
|
||||
Indexed int
|
||||
Failed bool
|
||||
}
|
||||
|
||||
// StreamView is the SSE-driven stage demonstration page.
|
||||
StreamView struct {
|
||||
Layout LayoutView
|
||||
Frames int
|
||||
Fragment []LinkView
|
||||
}
|
||||
|
||||
// NotFoundView is the 404 page.
|
||||
NotFoundView struct {
|
||||
Layout LayoutView
|
||||
}
|
||||
)
|
||||
|
||||
// MarkdownBody wraps pre-rendered Markdown HTML.
|
||||
func MarkdownBody(html string) templ.Component { return templ.Raw(html) }
|
||||
|
||||
// SafeSnippet escapes a search snippet but keeps the <mark> tags the index
|
||||
// inserted. Content is in-repo and trusted; escaping is defence in depth.
|
||||
func SafeSnippet(s string) templ.Component {
|
||||
s = stdhtml.EscapeString(s)
|
||||
s = strings.ReplaceAll(s, "<mark>", "<mark>")
|
||||
s = strings.ReplaceAll(s, "</mark>", "</mark>")
|
||||
return templ.Raw(s)
|
||||
}
|
||||
|
||||
// ConfidenceClass maps a confidence value onto a CSS class. The value itself is
|
||||
// always displayed — never softened.
|
||||
func ConfidenceClass(c string) string {
|
||||
switch c {
|
||||
case content.ConfidenceDelivered:
|
||||
return "conf conf-delivered"
|
||||
case content.ConfidenceRunning:
|
||||
return "conf conf-running"
|
||||
case content.ConfidenceInProgress:
|
||||
return "conf conf-in-progress"
|
||||
case content.ConfidenceAbandoned:
|
||||
return "conf conf-abandoned"
|
||||
case content.ConfidenceHistoric:
|
||||
return "conf conf-historic"
|
||||
}
|
||||
return "conf"
|
||||
}
|
||||
|
||||
// ConfidenceNote is the sentence shown under a status badge.
|
||||
func ConfidenceNote(c string) string {
|
||||
switch c {
|
||||
case content.ConfidenceDelivered:
|
||||
return "Delivered."
|
||||
case content.ConfidenceRunning:
|
||||
return "Running today."
|
||||
case content.ConfidenceInProgress:
|
||||
return "In progress — not finished."
|
||||
case content.ConfidenceAbandoned:
|
||||
return "Abandoned. Kept in the record deliberately."
|
||||
case content.ConfidenceHistoric:
|
||||
return "Historic — worked on, not maintained today."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LevelLabel names a depth level for the page kicker.
|
||||
func LevelLabel(level int) string {
|
||||
switch level {
|
||||
case 1:
|
||||
return "Level 1 · capability in one screen"
|
||||
case 2:
|
||||
return "Level 2 · what is actually run"
|
||||
case 3:
|
||||
return "Level 3 · artefact"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user