// 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
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
}