WIP Phase 2: content loader, stage contract, search index, templ components
This commit is contained in:
303
internal/stage/stage.go
Normal file
303
internal/stage/stage.go
Normal file
@@ -0,0 +1,303 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user