package adminconsole import ( "bytes" "embed" "fmt" "html/template" "io" "io/fs" "path" "strings" ) //go:embed templates var templatesFS embed.FS //go:embed assets var assetsFS embed.FS // Renderer holds the parsed admin console templates. It composes one template // set per content page, each combining the shared layout (defining the page // chrome and the "layout" entry template) with that page's "content" block, so // rendering a page is a single ExecuteTemplate call against the "layout" name. type Renderer struct { pages map[string]*template.Template } // PageData is the view model passed to every admin console page. Title is the // document title; Username is the authenticated operator; CSRFToken is the // per-operator token embedded into state-changing forms; ActiveNav marks the // highlighted navigation entry; Data carries the page-specific payload. type PageData struct { Title string Username string CSRFToken string ActiveNav string Data any } // NewRenderer parses the embedded layout and every content page under // templates/pages, returning a Renderer ready to serve them. It fails when a // template cannot be parsed. func NewRenderer() (*Renderer, error) { base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml") if err != nil { return nil, fmt.Errorf("parse admin console layout: %w", err) } pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml") if err != nil { return nil, fmt.Errorf("enumerate admin console pages: %w", err) } if len(pageFiles) == 0 { return nil, fmt.Errorf("admin console: no page templates found under templates/pages") } pages := make(map[string]*template.Template, len(pageFiles)) for _, file := range pageFiles { name := strings.TrimSuffix(path.Base(file), ".gohtml") clone, err := base.Clone() if err != nil { return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err) } if _, err := clone.ParseFS(templatesFS, file); err != nil { return nil, fmt.Errorf("parse admin console page %q: %w", name, err) } pages[name] = clone } return &Renderer{pages: pages}, nil } // MustNewRenderer is like NewRenderer but panics on error. The templates are // embedded at build time, so a parse failure is a programmer error rather than // a runtime condition. func MustNewRenderer() *Renderer { renderer, err := NewRenderer() if err != nil { panic(err) } return renderer } // Render writes the named page, wrapped in the shared layout, to w using data. // It returns an error when page is unknown or template execution fails; the // page is rendered into an intermediate buffer first so a mid-render failure // never emits a partial document to w. func (r *Renderer) Render(w io.Writer, page string, data PageData) error { tmpl, ok := r.pages[page] if !ok { return fmt.Errorf("admin console: unknown page %q", page) } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil { return fmt.Errorf("render admin console page %q: %w", page, err) } _, err := buf.WriteTo(w) return err } // Assets returns the embedded static asset tree rooted at the assets directory, // suitable for serving under `/_gm/assets/`. func Assets() (fs.FS, error) { return fs.Sub(assetsFS, "assets") }