Files
ai-resume/internal/web/templates/components.templ
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

344 lines
10 KiB
Plaintext

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"
}