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
510 lines
15 KiB
Go
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() }
|