Phase 7 (AUD-149), cheapest first. - internal/brand: vendored Simple Icons path data (CC0) for 50 marks, looked up by the technology label the content already uses. Inline SVG, 16 px, currentColor, no request and no font. tools/gen_brand_icons.sh regenerates it. - Six hand-drawn area icons replace the two-letter badges in the nav and on the landing cards; the evidence index gets a seventh. - TechChips renders a mark where one exists and plain text where one does not, so a missing icon reads as a decision rather than a broken image. - Client wall: 45 names as one typeface, one size, one grid of equal tiles, and the reason for no logo collage written on the page. No mark invented. - Two diagrams, authored in-repo: homelab topology (platform-network.md) and the RAG pipeline (ai-rag-corpus.md), each referenced from the page it explains. - Content images now carry intrinsic width/height, loading="lazy" and decoding="async" at render time; a site test fails the build if an image ships without them. - Topbar is two rows at desktop: the area names plus marks no longer overlap the search form (Lighthouse target-size was failing at 1440 px). - Favicon, so the browser stops requesting a missing /favicon.ico. Satisfies: logos on every area page, area icons in nav and cards, two diagrams referenced from their pages, images sized and lazy, Lighthouse >= 95 held. (cherry picked from commit a473e4738a3751418c2097df2bcf0568fb6d0208)
518 lines
15 KiB
Go
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 + "/",
|
|
Area: a.Key,
|
|
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() }
|