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
318 lines
9.6 KiB
Go
318 lines
9.6 KiB
Go
// Package stage defines the presentation panel as a content sink rather than a
|
|
// page renderer.
|
|
//
|
|
// The contract (docs/phase-2-static-site.md §2.4):
|
|
//
|
|
// Stage ← driver(route) // Mode A: HTMX fragment swap, pre-rendered files
|
|
// Stage ← driver(SSE) // Mode B: streamed fragments (Phase 5)
|
|
//
|
|
// Layout code renders a Binding. It never asks which driver produced it, so
|
|
// re-pointing the panel at a stream is a one-line change at the call site in
|
|
// Mode B's page and no change at all to Layout or Stage.
|
|
package stage
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
|
|
"github.com/a-h/templ"
|
|
)
|
|
|
|
// Fragment kinds the panel can display. These are the whole vocabulary: adding
|
|
// one is a deliberate change to §2.4.
|
|
const (
|
|
KindText = "text"
|
|
KindCode = "code"
|
|
KindImage = "image"
|
|
KindVideo = "video"
|
|
KindDiagram = "diagram"
|
|
KindLinks = "links"
|
|
)
|
|
|
|
// PanelID is the element both drivers swap into. The templates render this id
|
|
// and the bindings target it, so there is one name for it in the codebase.
|
|
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
|
|
|
|
const (
|
|
// ModeRoute pulls one pre-rendered fragment per interaction. Mode A.
|
|
ModeRoute Mode = "route"
|
|
// ModeStream receives fragments pushed over SSE. Mode B.
|
|
ModeStream Mode = "stream"
|
|
)
|
|
|
|
// Code is a highlighted code exhibit.
|
|
type Code struct {
|
|
Language string
|
|
Label string
|
|
HTML templ.Component // chroma-highlighted, or a <pre> fallback
|
|
}
|
|
|
|
// Link is one row of a link-list fragment.
|
|
type Link struct {
|
|
Label string
|
|
Href string
|
|
Note string
|
|
}
|
|
|
|
// Fragment is one thing the panel can show. The same struct is rendered to a
|
|
// static file for the route driver and pushed over SSE for the stream driver.
|
|
type Fragment struct {
|
|
ID string
|
|
Kind string
|
|
Title string
|
|
Subtitle string
|
|
Body templ.Component // pre-rendered HTML for text fragments
|
|
Code *Code
|
|
Src string // image, video or diagram source
|
|
Alt string
|
|
Links []Link
|
|
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.
|
|
// Route mode contributes nothing but a marker; stream mode contributes the SSE
|
|
// wiring. Both are rendered by the same Stage template.
|
|
type Binding struct {
|
|
Mode Mode
|
|
Endpoint string // stream: the SSE endpoint
|
|
Event string // stream: the event name to swap on
|
|
Target string // CSS selector the driver swaps into
|
|
Swap string // htmx swap style
|
|
Label string // human description of the driver, shown in the empty state
|
|
Hint string // one line telling the reader what to do
|
|
}
|
|
|
|
// Attributes returns the htmx/SSE attributes for the panel. Only the stream
|
|
// driver needs any: in route mode the trigger elements carry hx-get and
|
|
// hx-target, because a fragment is chosen by clicking evidence.
|
|
func (b Binding) Attributes() templ.Attributes {
|
|
attrs := templ.Attributes{"data-stage-driver": string(b.Mode)}
|
|
switch b.Mode {
|
|
case ModeStream:
|
|
attrs["hx-ext"] = "sse"
|
|
attrs["sse-connect"] = b.Endpoint
|
|
attrs["sse-swap"] = b.Event
|
|
attrs["hx-swap"] = b.Swap
|
|
attrs["aria-live"] = "polite"
|
|
}
|
|
return attrs
|
|
}
|
|
|
|
// TargetSelector is the panel a link or a stream should swap into.
|
|
func (b Binding) TargetSelector() string {
|
|
if b.Target == "" {
|
|
return PanelTarget()
|
|
}
|
|
return b.Target
|
|
}
|
|
|
|
// Driver is the whole contract between the panel and whatever feeds it.
|
|
type Driver interface {
|
|
Mode() Mode
|
|
// Bind returns the binding to render on the Stage panel.
|
|
Bind() Binding
|
|
}
|
|
|
|
// RouteDriver feeds the panel from pre-rendered fragment files. Mode A.
|
|
type RouteDriver struct {
|
|
// BaseURL is where fragment files are served from, e.g. "/stage".
|
|
BaseURL string
|
|
Label string
|
|
Hint string
|
|
}
|
|
|
|
// Mode is part of the Driver contract.
|
|
func (RouteDriver) Mode() Mode { return ModeRoute }
|
|
|
|
// Bind is part of the Driver contract.
|
|
func (d RouteDriver) Bind() Binding {
|
|
return Binding{
|
|
Mode: ModeRoute,
|
|
Target: PanelTarget(),
|
|
Swap: "innerHTML",
|
|
Label: defaultString(d.Label, "Evidence panel · driven by route"),
|
|
Hint: defaultString(d.Hint, "Choose a claim from the evidence table; the artefact appears here."),
|
|
}
|
|
}
|
|
|
|
// FragmentURL is the pre-rendered URL for a fragment id.
|
|
func (d RouteDriver) FragmentURL(id string) string {
|
|
base := d.BaseURL
|
|
if base == "" {
|
|
base = "/stage"
|
|
}
|
|
return base + "/" + id
|
|
}
|
|
|
|
// SSEDriver feeds the panel from an event stream. Same panel, same fragments,
|
|
// different transport. Used by the Phase 5 console; demonstrated in Phase 2 by
|
|
// a stream of the pre-built fragments so the plumbing is provable without AI.
|
|
type SSEDriver struct {
|
|
Endpoint string
|
|
Event string
|
|
Label string
|
|
Hint string
|
|
}
|
|
|
|
// Mode is part of the Driver contract.
|
|
func (SSEDriver) Mode() Mode { return ModeStream }
|
|
|
|
// Bind is part of the Driver contract.
|
|
func (d SSEDriver) Bind() Binding {
|
|
event := d.Event
|
|
if event == "" {
|
|
event = "stage"
|
|
}
|
|
return Binding{
|
|
Mode: ModeStream,
|
|
Endpoint: d.Endpoint,
|
|
Event: event,
|
|
Target: PanelTarget(),
|
|
Swap: "innerHTML",
|
|
Label: defaultString(d.Label, "Evidence panel · driven by stream"),
|
|
Hint: defaultString(d.Hint, "Frames arrive as they are produced and replace the panel each time."),
|
|
}
|
|
}
|
|
|
|
func defaultString(v, fallback string) string {
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Source produces the fragments a stream driver pushes. Phase 2 provides
|
|
// ManifestSource; Phase 5 provides the retrieval-driven source. Neither
|
|
// touches the panel.
|
|
type Source interface {
|
|
// Stream sends fragments for keys, in order, until done or ctx is done.
|
|
// Returning an error ends the stream; the panel keeps the last fragment.
|
|
Stream(ctx context.Context, keys []string, send func(Fragment) error) error
|
|
}
|
|
|
|
// Entry is one pre-rendered fragment, as recorded at build time.
|
|
type Entry struct {
|
|
ID string `json:"id"`
|
|
DocID string `json:"doc"`
|
|
Title string `json:"title"`
|
|
Kind string `json:"kind"`
|
|
// File is relative to the published root, e.g. "stage/edge-fleet/1.html".
|
|
File string `json:"file"`
|
|
}
|
|
|
|
// Manifest is the build-time index of fragments. The server uses it to stream
|
|
// fragments that were rendered once, and the route driver serves the same
|
|
// files directly.
|
|
type Manifest struct {
|
|
Entries []Entry `json:"entries"`
|
|
}
|
|
|
|
// ByID looks an entry up.
|
|
func (m *Manifest) ByID(id string) (Entry, bool) {
|
|
for _, e := range m.Entries {
|
|
if e.ID == id {
|
|
return e, true
|
|
}
|
|
}
|
|
return Entry{}, false
|
|
}
|
|
|
|
// ForDoc returns the entries belonging to a document, oldest first.
|
|
func (m *Manifest) ForDoc(docID string) []Entry {
|
|
var out []Entry
|
|
for _, e := range m.Entries {
|
|
if e.DocID == docID {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Marshal encodes the manifest for the build to write.
|
|
func (m *Manifest) Marshal() ([]byte, error) { return json.MarshalIndent(m, "", " ") }
|
|
|
|
// LoadManifest parses a manifest written by the build.
|
|
func LoadManifest(b []byte) (*Manifest, error) {
|
|
var m Manifest
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil, fmt.Errorf("stage manifest: %w", err)
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// ManifestSource streams fragments that were pre-rendered at build time. It
|
|
// exists to prove the stream path end to end without a model in the loop; the
|
|
// Phase 5 source implements the same interface with retrieval behind it.
|
|
type ManifestSource struct {
|
|
manifest *Manifest
|
|
// HTML returns the pre-rendered markup for an entry.
|
|
HTML func(Entry) (string, error)
|
|
// Frame is an optional hook called as each fragment is sent, for pacing.
|
|
Frame func(index int)
|
|
}
|
|
|
|
// NewManifestSource wires a manifest and a reader together.
|
|
func NewManifestSource(m *Manifest, read func(Entry) (string, error), frame func(int)) *ManifestSource {
|
|
return &ManifestSource{manifest: m, HTML: read, Frame: frame}
|
|
}
|
|
|
|
// Stream is part of the Source contract.
|
|
func (s *ManifestSource) Stream(ctx context.Context, keys []string, send func(Fragment) error) error {
|
|
entries := make([]Entry, 0, len(keys))
|
|
if len(keys) == 0 {
|
|
entries = append(entries, s.manifest.Entries...)
|
|
}
|
|
for _, k := range keys {
|
|
if e, ok := s.manifest.ByID(k); ok {
|
|
entries = append(entries, e)
|
|
continue
|
|
}
|
|
// A document id expands to that document's fragments.
|
|
entries = append(entries, s.manifest.ForDoc(k)...)
|
|
}
|
|
sort.SliceStable(entries, func(i, j int) bool { return entries[i].ID < entries[j].ID })
|
|
|
|
for i, e := range entries {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
html, err := s.HTML(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.Frame != nil {
|
|
s.Frame(i)
|
|
}
|
|
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
|
|
}
|