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
This commit is contained in:
@@ -38,6 +38,16 @@ const PanelID = "stage-content"
|
||||
// PanelTarget is the CSS selector for PanelID.
|
||||
func PanelTarget() string { return "#" + PanelID }
|
||||
|
||||
// DocFragmentID is the id of the fragment that carries a document's own body.
|
||||
// The shape lives here, with the rest of the panel contract, so that a row in an
|
||||
// evidence table, a pre-rendered file name and a streamed frame all agree on it.
|
||||
func DocFragmentID(docID string) string { return "doc-" + docID }
|
||||
|
||||
// ArtefactFragmentID is the id of the i-th artefact in a document. It is
|
||||
// derived from the document and the artefact's position, so it stays stable
|
||||
// across builds and a saved link keeps working when the content is re-rendered.
|
||||
func ArtefactFragmentID(docID string, i int) string { return fmt.Sprintf("art-%s-%02d", docID, i+1) }
|
||||
|
||||
// Mode is how a panel receives content.
|
||||
type Mode string
|
||||
|
||||
@@ -77,6 +87,11 @@ type Fragment struct {
|
||||
DocURL string
|
||||
DocTitle string
|
||||
Confidence string
|
||||
// Rendered marks Body as a complete fragment: the build already rendered
|
||||
// the article and everything inside it, so a transport writes these bytes
|
||||
// as-is instead of wrapping them again. A source that hands over structured
|
||||
// content leaves it false and the panel renders the fragment normally.
|
||||
Rendered bool
|
||||
}
|
||||
|
||||
// Binding is everything the Stage component needs to know about its driver.
|
||||
@@ -293,11 +308,10 @@ func (s *ManifestSource) Stream(ctx context.Context, keys []string, send func(Fr
|
||||
if s.Frame != nil {
|
||||
s.Frame(i)
|
||||
}
|
||||
frag := Fragment{ID: e.ID, Kind: e.Kind, Title: e.Title, Body: templ.Raw(html)}
|
||||
frag := Fragment{ID: e.ID, Kind: e.Kind, Title: e.Title, Body: templ.Raw(html), Rendered: true}
|
||||
if err := send(frag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
148
internal/stage/stage_test.go
Normal file
148
internal/stage/stage_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRouteDriverBindsWithoutTransport checks §2.4: in route mode the panel
|
||||
// carries no stream wiring at all, because the trigger elements do the work.
|
||||
func TestRouteDriverBindsWithoutTransport(t *testing.T) {
|
||||
d := RouteDriver{BaseURL: "/stage"}
|
||||
if d.Mode() != ModeRoute {
|
||||
t.Fatalf("Mode() = %q, want %q", d.Mode(), ModeRoute)
|
||||
}
|
||||
b := d.Bind()
|
||||
if b.Mode != ModeRoute {
|
||||
t.Errorf("binding mode = %q", b.Mode)
|
||||
}
|
||||
if b.TargetSelector() != PanelTarget() {
|
||||
t.Errorf("target = %q, want %q", b.TargetSelector(), PanelTarget())
|
||||
}
|
||||
for _, k := range []string{"hx-ext", "sse-connect", "sse-swap"} {
|
||||
if _, ok := b.Attributes()[k]; ok {
|
||||
t.Errorf("route binding has %q; route mode must not open a stream", k)
|
||||
}
|
||||
}
|
||||
if got := d.FragmentURL("doc-x"); got != "/stage/doc-x" {
|
||||
t.Errorf("FragmentURL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEDriverChangesOnlyTheBinding is the design contract: the same panel,
|
||||
// the same target, a different transport.
|
||||
func TestSSEDriverChangesOnlyTheBinding(t *testing.T) {
|
||||
sse := SSEDriver{Endpoint: "/api/stage/stream"}.Bind()
|
||||
route := RouteDriver{}.Bind()
|
||||
|
||||
if sse.TargetSelector() != route.TargetSelector() {
|
||||
t.Errorf("stream targets %q, route targets %q; the panel must not move",
|
||||
sse.TargetSelector(), route.TargetSelector())
|
||||
}
|
||||
if sse.Mode == route.Mode {
|
||||
t.Fatal("stream and route bindings have the same mode")
|
||||
}
|
||||
attrs := sse.Attributes()
|
||||
want := map[string]string{
|
||||
"hx-ext": "sse",
|
||||
"sse-connect": "/api/stage/stream",
|
||||
"sse-swap": "stage",
|
||||
"hx-swap": "innerHTML",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got := attrs[k]; got != v {
|
||||
t.Errorf("attribute %s = %v, want %q", k, got, v)
|
||||
}
|
||||
}
|
||||
if attrs["data-stage-driver"] != string(ModeStream) {
|
||||
t.Errorf("driver marker = %v", attrs["data-stage-driver"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestManifestSourceServesPreRenderedFragments checks the Phase 2 source: it
|
||||
// expands a document id, keeps order and marks the payload as already rendered
|
||||
// so a transport does not wrap the article twice.
|
||||
func TestManifestSourceServesPreRenderedFragments(t *testing.T) {
|
||||
m := &Manifest{Entries: []Entry{
|
||||
{ID: "doc-a", DocID: "a", Kind: KindText, Title: "A", File: "stage/doc-a.html"},
|
||||
{ID: "art-a-01", DocID: "a", Kind: KindCode, Title: "code", File: "stage/art-a-01.html"},
|
||||
{ID: "doc-b", DocID: "b", Kind: KindText, Title: "B", File: "stage/doc-b.html"},
|
||||
}}
|
||||
read := func(e Entry) (string, error) { return "<article>" + e.ID + "</article>", nil }
|
||||
src := NewManifestSource(m, read, nil)
|
||||
|
||||
var got []Fragment
|
||||
err := src.Stream(context.Background(), []string{"a"}, func(f Fragment) error {
|
||||
got = append(got, f)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("streamed %d fragments for doc a, want 2", len(got))
|
||||
}
|
||||
for _, f := range got {
|
||||
if !f.Rendered {
|
||||
t.Errorf("fragment %s is not marked Rendered; a transport would wrap it again", f.ID)
|
||||
}
|
||||
}
|
||||
if got[0].ID != "art-a-01" || got[1].ID != "doc-a" {
|
||||
t.Errorf("order = %s,%s; wanted the stable manifest order", got[0].ID, got[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManifestSourceStopsOnCancel checks that a disconnected visitor stops the
|
||||
// stream instead of leaving a goroutine producing frames (acceptance test N7).
|
||||
func TestManifestSourceStopsOnCancel(t *testing.T) {
|
||||
m := &Manifest{Entries: []Entry{
|
||||
{ID: "doc-a", DocID: "a", Kind: KindText, File: "stage/doc-a.html"},
|
||||
{ID: "doc-b", DocID: "b", Kind: KindText, File: "stage/doc-b.html"},
|
||||
}}
|
||||
read := func(e Entry) (string, error) { return "<article></article>", nil }
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
src := NewManifestSource(m, read, func(i int) {
|
||||
if i == 0 {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
|
||||
sent := 0
|
||||
err := src.Stream(ctx, nil, func(Fragment) error { sent++; return nil })
|
||||
if err == nil {
|
||||
t.Fatal("Stream returned nil after cancellation")
|
||||
}
|
||||
if sent >= len(m.Entries) {
|
||||
t.Errorf("sent all %d frames after cancellation; the stream ignored the context", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragmentIDsAreStable keeps the link between an evidence row and its
|
||||
// fragment stable across builds.
|
||||
func TestFragmentIDsAreStable(t *testing.T) {
|
||||
if DocFragmentID("edge-fleet") != "doc-edge-fleet" {
|
||||
t.Error("document fragment id changed shape")
|
||||
}
|
||||
if ArtefactFragmentID("edge-fleet", 0) != "art-edge-fleet-01" {
|
||||
t.Errorf("artefact fragment id = %q", ArtefactFragmentID("edge-fleet", 0))
|
||||
}
|
||||
}
|
||||
|
||||
// TestKindsAreTheWholeVocabulary guards §2.4: the panel can show six things.
|
||||
func TestKindsAreTheWholeVocabulary(t *testing.T) {
|
||||
kinds := []string{KindText, KindCode, KindImage, KindVideo, KindDiagram, KindLinks}
|
||||
seen := map[string]bool{}
|
||||
for _, k := range kinds {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
t.Fatal("empty fragment kind")
|
||||
}
|
||||
if seen[k] {
|
||||
t.Errorf("duplicate fragment kind %q", k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
if len(kinds) != 6 {
|
||||
t.Errorf("%d fragment kinds, want 6", len(kinds))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user