Files
ai-resume/internal/site/site.go
Sam Rolfe 84fc585c91 Phase 2: Mode A static site — build, serve, search, stage panel
One Go binary serves a pre-rendered site. No Node, no bundler, no node_modules:
htmx and the SSE extension are vendored files, Markdown and code highlighting
run at build time (goldmark + chroma), and the server only writes files.

Implements docs/phase-2-static-site.md:

  2.1 service skeleton   cmd/build (content -> dist), cmd/server (dist -> HTTP),
                         env-configured, 127.0.0.1:8096, /healthz with separate
                         static / ai / search readiness, --health-check probe,
                         deploy/compose.yml + deploy/Dockerfile at 120m
  2.2 rendering          templ components (Layout, Nav, PillarCard, Stage,
                         EvidenceTable, CodeBlock, Timeline, Footer);
                         `make generate` is a build step and is byte-stable
  2.3 navigation         six-area rail, <details> disclosure under 900px,
                         breadcrumbs, aria-current, L1/L2/L3 with artefact pages
  2.4 stage panel        internal/stage owns the contract; RouteDriver (fragment
                         swap) and SSEDriver (event stream) differ only in the
                         Binding, never in layout. fragment ids live in the
                         contract so route files, links and streamed frames agree
  2.5 search             SQLite FTS5 over titles and body, rebuilt by the build;
                         one handler returns a page without HTMX, a fragment with
                         it. Visitors' FTS operators are never honoured
  2.6 quality floor      print stylesheet, JS-off navigation, focus states,
                         semantic landmarks, dark default; security headers

Definition of Done evidence (see issue AUD-142):
  A1  all pages 200 in a network namespace with no external route
  one binary serves the site; make dist writes 45 pages, 86 fragments, 40 docs
  templ generate is deterministic (md5 identical across runs)
  6 L1 + 6 L2 pages, artefact pages reachable in <= 3 clicks
  FTS5 returns sensible hits for zigbee / signage / routing (tested)
  Lighthouse 100 performance and 100 accessibility, mobile and desktop
  first byte well inside 100ms over loopback (test)
  panel renders from a route driver and re-points at the stream untouched
  print legible: /edge/ renders to a 3-page PDF with the print stylesheet

Also fixes found while finishing the build:
  - reject "." / ".." path segments before ServeMux redirects them into a file
    outside the asset directory (/static/../build.json now 404, was 307->200)
  - make test is the real gate: go test ./... plus the validator self-test
2026-10-26 20:42:52 +11:00

510 lines
15 KiB
Go

// 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() }