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
201 lines
5.4 KiB
Go
201 lines
5.4 KiB
Go
package site
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
|
|
"github.com/a-h/templ"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
|
)
|
|
|
|
// buildFragments renders every stage fragment once, in manifest order:
|
|
//
|
|
// 1. every document's own fragment, L1 first in area order
|
|
// 2. every artefact fragment, in the same document order
|
|
//
|
|
// The order matters: the first len(content.Areas) entries are the six area
|
|
// landing fragments, which is the default stream, and the server reads that
|
|
// order out of the manifest without loading the content tree. The panel is a
|
|
// content sink, so this is the only place that decides what a fragment is.
|
|
// Neither driver renders anything itself.
|
|
func (s *Site) buildFragments() error {
|
|
order := make([]*content.Document, 0, len(s.Content.Docs))
|
|
for _, a := range content.Areas {
|
|
for _, d := range s.PublicInArea(a.Key) {
|
|
if d.Level == 1 {
|
|
order = append(order, d)
|
|
}
|
|
}
|
|
}
|
|
rest := make([]*content.Document, 0, len(s.Content.Docs))
|
|
for _, a := range content.Areas {
|
|
for _, d := range s.PublicInArea(a.Key) {
|
|
if d.Level != 1 {
|
|
rest = append(rest, d)
|
|
}
|
|
}
|
|
}
|
|
order = append(order, rest...)
|
|
|
|
for _, d := range order {
|
|
if err := s.addFragment(s.docFragment(d)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, d := range order {
|
|
for i, a := range d.Artefacts {
|
|
f, err := s.artefactFragment(d, a, stage.ArtefactFragmentID(d.ID, i))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.addFragment(f); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Site) addFragment(f *Fragment) error {
|
|
if _, dup := s.fragByID[f.ID]; dup {
|
|
return fmt.Errorf("stage fragment id %q is not unique", f.ID)
|
|
}
|
|
html, err := renderFragment(f.component())
|
|
if err != nil {
|
|
return fmt.Errorf("render stage fragment %s: %w", f.ID, err)
|
|
}
|
|
f.HTML = html
|
|
s.Fragments = append(s.Fragments, f)
|
|
s.fragByID[f.ID] = f
|
|
s.FragmentsByDoc[f.DocID] = append(s.FragmentsByDoc[f.DocID], f.ID)
|
|
s.Manifest.Entries = append(s.Manifest.Entries, stage.Entry{
|
|
ID: f.ID,
|
|
DocID: f.DocID,
|
|
Title: f.Title,
|
|
Kind: f.Kind,
|
|
File: f.File,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// fragment builds the stage value this fragment renders from.
|
|
func (f *Fragment) component() stage.Fragment {
|
|
out := stage.Fragment{
|
|
ID: f.ID,
|
|
Kind: f.Kind,
|
|
Title: f.Title,
|
|
}
|
|
out.Subtitle = f.subtitle
|
|
out.Body = f.body
|
|
out.Code = f.code
|
|
out.Src = f.src
|
|
out.Alt = f.alt
|
|
out.Links = f.links
|
|
out.Confidence = f.confidence
|
|
out.DocURL = f.docURL
|
|
out.DocTitle = f.docTitle
|
|
return out
|
|
}
|
|
|
|
func renderFragment(f stage.Fragment) (string, error) {
|
|
var buf bytes.Buffer
|
|
if err := templates.FragmentView(f).Render(context.Background(), &buf); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// docFragment is the document's own body, shown in the panel when a reader
|
|
// asks for the page itself rather than one of its artefacts.
|
|
func (s *Site) docFragment(d *content.Document) *Fragment {
|
|
area, _ := content.AreaByKey(d.Area)
|
|
return &Fragment{
|
|
ID: stage.DocFragmentID(d.ID),
|
|
DocID: d.ID,
|
|
Kind: stage.KindText,
|
|
Title: d.Title,
|
|
subtitle: area.Title + " · " + templates.LevelLabel(d.Level),
|
|
body: templ.Raw(d.HTML),
|
|
confidence: d.Confidence,
|
|
docURL: d.URL,
|
|
docTitle: "Read the page",
|
|
File: "stage/" + stage.DocFragmentID(d.ID) + ".html",
|
|
}
|
|
}
|
|
|
|
// artefactFragment renders one artefact into the kind the panel understands.
|
|
func (s *Site) artefactFragment(d *content.Document, a content.Artefact, id string) (*Fragment, error) {
|
|
ra, err := s.Content.Resolve(d, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f := &Fragment{
|
|
ID: id,
|
|
DocID: d.ID,
|
|
Kind: ra.Kind,
|
|
Title: a.Label,
|
|
ArtefactURL: a.URL,
|
|
confidence: d.Confidence,
|
|
File: "stage/" + id + ".html",
|
|
}
|
|
if ra.Target != nil {
|
|
f.subtitle = ra.Target.Title
|
|
f.body = templ.Raw(ra.Target.HTML)
|
|
f.confidence = ra.Target.Confidence
|
|
f.docURL = ra.Target.URL
|
|
f.docTitle = "Open " + ra.Target.Title
|
|
return f, nil
|
|
}
|
|
f.subtitle = ra.Rel
|
|
switch ra.Kind {
|
|
case stage.KindCode:
|
|
src, err := os.ReadFile(ra.File)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: read code artefact: %w", d.Path, err)
|
|
}
|
|
var buf bytes.Buffer
|
|
lang := content.LanguageFor(ra.File)
|
|
if err := s.Renderer.Highlight(&buf, lang, src); err != nil {
|
|
return nil, fmt.Errorf("%s: highlight artefact: %w", d.Path, err)
|
|
}
|
|
f.code = &stage.Code{Language: lang, Label: path.Base(ra.Rel), HTML: templ.Raw(buf.String())}
|
|
case stage.KindImage, stage.KindDiagram, stage.KindVideo:
|
|
f.src = ra.Href
|
|
f.alt = a.Label
|
|
default:
|
|
f.links = []stage.Link{{Label: a.Label, Href: ra.Href, Note: ra.Rel}}
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// fragForEntry looks up a build-time fragment by manifest entry.
|
|
func (s *Site) fragForEntry(e stage.Entry) (*Fragment, bool) {
|
|
f, ok := s.fragByID[e.ID]
|
|
return f, ok
|
|
}
|
|
|
|
// FragmentHTML returns the pre-rendered markup for a fragment id. Both drivers
|
|
// serve these bytes: the route driver as a file, the stream driver as an event.
|
|
func (s *Site) FragmentHTML(id string) (string, bool) {
|
|
f, ok := s.fragByID[id]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return f.HTML, true
|
|
}
|
|
|
|
// FragmentsByID is a copy of the fragment index, for tests and the manifest.
|
|
func (s *Site) FragmentsByID() map[string]*Fragment {
|
|
out := make(map[string]*Fragment, len(s.fragByID))
|
|
for k, v := range s.fragByID {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|