Files
ai-resume/internal/site/site.go
Sam Rolfe d793ab0871 search: make the navigation form work from every page (AUD-151)
The nav form carried hx-get="/search" hx-target="#search-results", but
#search-results exists only on /search. On every other page htmx found no
target and silently discarded the response: a search box that looks like it
works and does nothing.

- SearchForm is now a plain GET with no htmx attributes (navigation and the
  landing page), so it navigates to /search?q=… from any page and with
  JavaScript disabled.
- SearchFormLive keeps the in-place swap on the search page, where the target
  does exist. For HX-Request the server now returns the block's *contents*
  (SearchResultsBody), so the fragment no longer nests a second element with
  id="search-results" inside the live region.
- Empty query shows a prompt instead of an empty list. A zero-match query says
  "Nothing found" and offers the six areas plus the evidence index.
- The result count is role="status" inside the existing aria-live container, so
  a screen reader reads "4 results for …" rather than a run of titles.

Tests (internal/server, internal/site):
- the nav form carries no htmx attribute on nine pages, and no built page
  carries an hx-target whose element is missing (walks all of dist/);
- search submitted from /, /edge/ and /platform/ lands on a page with results
  with no HX-Request header, i.e. the JavaScript-disabled path;
- empty query, zero match and the announced count;
- a content change followed by a build is visible in dist/search.db. That is
  the deploy path: deploy/deploy.sh runs `make dist` before it stages a
  release, and search.Build removes and rewrites the index every build.

DoD: search from home, /edge and /platform lands on results; works with
JavaScript disabled; zero-match query shows a message; content change followed
by a deploy is reflected in search; no htmx request targets a missing element.

(cherry picked from commit a56c795768e1e9b10204aae04c3612d7a96775a7)
2026-10-28 15:55:34 +11:00

518 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
}
// AreaLinks is the six areas as plain links. The search page offers them when a
// query matches nothing, so an empty result is still a way into the site.
func (Shell) AreaLinks() []templates.LinkView {
out := make([]templates.LinkView, 0, len(content.Areas))
for _, a := range content.Areas {
out = append(out, templates.LinkView{Label: a.Title, Href: "/" + a.Key + "/"})
}
return out
}
// 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,
Areas: sh.AreaLinks(),
}
}
// 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)
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,
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),
}
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})
}
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() }