304 lines
8.7 KiB
Go
304 lines
8.7 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 }
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)}
|
|
if err := send(frag); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|