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
175 lines
5.0 KiB
Go
175 lines
5.0 KiB
Go
package site
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
|
)
|
|
|
|
// newSite loads the repository's own content. If this fails, every other test
|
|
// here would be testing nothing.
|
|
func newSite(t *testing.T) *Site {
|
|
t.Helper()
|
|
root, err := filepath.Abs("../..")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := New(Config{Root: root, OutDir: filepath.Join(t.TempDir(), "dist")})
|
|
if err != nil {
|
|
t.Fatalf("load site: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// TestSixAreasHaveLandingPages is the DoD line "L1 (6) and L2 (6) pages
|
|
// complete": every area has a published level 1 page and at least one level 2
|
|
// page, and every area's L1 fragment is the default stream.
|
|
func TestSixAreasHaveLandingPages(t *testing.T) {
|
|
s := newSite(t)
|
|
if len(content.Areas) != 6 {
|
|
t.Fatalf("%d areas, want 6", len(content.Areas))
|
|
}
|
|
if got := len(s.StreamKeys()); got != 6 {
|
|
t.Fatalf("stream keys = %d, want the six area landing fragments", got)
|
|
}
|
|
for i, a := range content.Areas {
|
|
l1, ok := s.Content.AreaLanding(a.Key)
|
|
if !ok {
|
|
t.Errorf("%s: no level 1 page", a.Key)
|
|
continue
|
|
}
|
|
if !l1.Public {
|
|
t.Errorf("%s: level 1 page %s is not public", a.Key, l1.ID)
|
|
}
|
|
if f, ok := s.Fragment(s.StreamKeys()[i]); !ok {
|
|
t.Errorf("%s: stream key %s is not a fragment", a.Key, s.StreamKeys()[i])
|
|
} else if f.DocID != l1.ID {
|
|
t.Errorf("%s: stream fragment %s belongs to %s, want %s", a.Key, f.ID, f.DocID, l1.ID)
|
|
}
|
|
levels := map[int]int{}
|
|
for _, d := range s.PublicInArea(a.Key) {
|
|
levels[d.Level]++
|
|
}
|
|
if levels[2] == 0 {
|
|
t.Errorf("%s: no level 2 pages", a.Key)
|
|
}
|
|
if levels[3] == 0 {
|
|
t.Errorf("%s: no level 3 pages", a.Key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryArtefactIsReachableWithinThreeClicks is acceptance test C4 against
|
|
// the view models: landing → area → artefact page is three clicks, and every
|
|
// artefact has a working link.
|
|
func TestEveryArtefactIsReachableWithinThreeClicks(t *testing.T) {
|
|
s := newSite(t)
|
|
for _, area := range content.Areas {
|
|
l1, ok := s.Content.AreaLanding(area.Key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
linkedFromL1 := map[string]bool{}
|
|
for _, l := range s.Doc(l1).SubPages {
|
|
linkedFromL1[l.Href] = true
|
|
}
|
|
for _, l := range s.Doc(l1).Evidence {
|
|
linkedFromL1[l.Href] = true
|
|
}
|
|
|
|
for _, d := range s.PublicInArea(area.Key) {
|
|
if d.Level == 1 {
|
|
continue
|
|
}
|
|
if d.Level == 2 {
|
|
if !linkedFromL1[d.URL] {
|
|
t.Errorf("%s/level 2 page %s is not linked from the area landing page", area.Key, d.URL)
|
|
}
|
|
continue
|
|
}
|
|
// Level 3: linked from an L2 page's drill-down or evidence table.
|
|
reachable := false
|
|
for _, l2 := range s.PublicInArea(area.Key) {
|
|
if l2.Level != 2 {
|
|
continue
|
|
}
|
|
view := s.Doc(l2)
|
|
for _, l := range view.SubPages {
|
|
if l.Href == d.URL {
|
|
reachable = true
|
|
}
|
|
}
|
|
for _, l := range view.Evidence {
|
|
if l.Href == d.URL {
|
|
reachable = true
|
|
}
|
|
}
|
|
}
|
|
if !reachable {
|
|
t.Errorf("%s/level 3 page %s is not linked from any level 2 page", area.Key, d.URL)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryArtefactHasAStageFragment keeps the panel honest: if an evidence row
|
|
// offers hx-get, the fragment it names must exist.
|
|
func TestEveryArtefactHasAStageFragment(t *testing.T) {
|
|
s := newSite(t)
|
|
for _, d := range s.PublicDocs() {
|
|
for _, l := range s.evidence(d) {
|
|
if l.HXGet == "" {
|
|
t.Errorf("%s: evidence %q has no stage fragment", d.ID, l.Label)
|
|
continue
|
|
}
|
|
id := strings.TrimPrefix(l.HXGet, "/stage/")
|
|
if _, ok := s.Fragment(id); !ok {
|
|
t.Errorf("%s: evidence %q points at missing fragment %q", d.ID, l.Label, id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildWritesEveryPublishedPage runs the real build once and checks the
|
|
// files the server depends on.
|
|
func TestBuildWritesEveryPublishedPage(t *testing.T) {
|
|
s := newSite(t)
|
|
if err := s.Build(); err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
for _, url := range s.PublishedPages() {
|
|
file := s.filePathForURL(url)
|
|
if !strings.HasPrefix(file, s.Config.OutDir) {
|
|
t.Errorf("%s: output escapes the build directory", url)
|
|
}
|
|
if _, err := os.ReadFile(file); err != nil {
|
|
t.Errorf("%s: %v", url, err)
|
|
}
|
|
}
|
|
for _, name := range []string{IndexFile, ManifestFile, BuildInfoFile, "static/site.css", "static/htmx.min.js", "static/chroma.css"} {
|
|
if _, err := os.ReadFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(name))); err != nil {
|
|
t.Errorf("build output %s: %v", name, err)
|
|
}
|
|
}
|
|
info, err := LoadBuildInfo(s.Config.OutDir)
|
|
if err != nil {
|
|
t.Fatalf("build info: %v", err)
|
|
}
|
|
if info.Pages == 0 || info.Fragments == 0 || info.Indexed == 0 {
|
|
t.Errorf("build info is empty: %+v", info)
|
|
}
|
|
// The manifest order is the server's default stream: six document
|
|
// fragments first, and they are the six area landings.
|
|
if len(s.Manifest.Entries) < 6 {
|
|
t.Fatalf("manifest has %d entries", len(s.Manifest.Entries))
|
|
}
|
|
for _, e := range s.Manifest.Entries[:6] {
|
|
if !strings.HasPrefix(e.ID, "doc-") {
|
|
t.Errorf("manifest entry %s is not a document fragment; the default stream must be the six areas", e.ID)
|
|
}
|
|
}
|
|
}
|