// Package handlers holds the HTTP handlers for the OpenYield web UI screens. // // Each screen (Reach signup, Stash dashboard, Window authorization, Standing // progress, Bloom accrual) gets its own handler file. handlers/server.go wires // routes into the mux from web/server.go. Handlers render html/template // templates against the mock store (web/store). Lexicon-clean by construction // (REQ-012 / REQ-045): the lexicon_meta_web firewall scans these files. package handlers import ( "fmt" "html/template" "net/http" "os" "path/filepath" "github.com/oy/openyield/web/store" standingtypes "github.com/oy/openyield/x/standing/types" windowtypes "github.com/oy/openyield/x/window/types" ) // Server bundles the mock store + per-page templates + route registration. // Each screen handler is a method on Server so it shares the store + tmpl. // // Template loading: base.html is parsed once, then each page template is // parsed in a CLONE of the base set so the per-page "content" block does not // collide across pages (Go html/template shares the block namespace within // one set; cloning per page isolates each page's content block). This is the // standard Go template pattern for layouts + pages. type Server struct { Store *store.Store Pages map[string]*template.Template } // New constructs a Server with the given store + per-page templates loaded // from templatesDir (the absolute or relative path to web/templates/). func New(s *store.Store, templatesDir string) (*Server, error) { funcs := template.FuncMap{ "divGrain": func(grain, unit int64) int64 { if unit == 0 { return 0 } return grain / unit }, "string": func(v any) string { switch t := v.(type) { case string: return t case windowtypes.WindowStatus: return string(t) case standingtypes.StandingBucket: return string(t) default: return "" } }, } basePath := filepath.Join(templatesDir, "base.html") base, err := template.New("base.html").Funcs(funcs).ParseFiles(basePath) if err != nil { return nil, fmt.Errorf("parse base: %w", err) } pages := map[string]*template.Template{} pageGlob := filepath.Join(templatesDir, "*.html") matches, err := filepath.Glob(pageGlob) if err != nil { return nil, fmt.Errorf("glob pages: %w", err) } for _, p := range matches { name := filepath.Base(p) if name == "base.html" { continue } clone, cerr := base.Clone() if cerr != nil { return nil, fmt.Errorf("clone for %s: %w", name, cerr) } pt, perr := clone.ParseFiles(p) if perr != nil { return nil, fmt.Errorf("parse %s: %w", name, perr) } pages[name] = pt } return &Server{Store: s, Pages: pages}, nil } // Register wires all screen routes into the given mux (Go 1.22 method // patterns). Called by web/server.go after constructing the Server. func (s *Server) Register(mux *http.ServeMux) { s.registerReach(mux) s.registerStash(mux) s.registerWindow(mux) s.registerStanding(mux) s.registerBloom(mux) } // render executes the named page template with the given data, writing HTML // to w. The page template invokes base.html and overrides the "content" block. func (s *Server) render(w http.ResponseWriter, name string, data any) { tmpl, ok := s.Pages[name] if !ok { http.Error(w, "template not found: "+name, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil { http.Error(w, "render error", http.StatusInternalServerError) } } // RenderHome renders the home page (public so web/server.go can call it for // the "/" route which lives outside handlers.Register). func (s *Server) RenderHome(w http.ResponseWriter, data any) { s.render(w, "home.html", data) } // templatesDir returns the default web/templates directory relative to the // working directory. Used by web/server.go when constructing via New(). func DefaultTemplatesDir() string { dir, _ := os.Getwd() if filepath.Base(dir) == "web" || filepath.Base(dir) == "handlers" { return filepath.Join(dir, "templates") } return "web/templates" }