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 (the page chrome and the // "layout" entry template) with that page's "content" block, so rendering a page // is a single ExecuteTemplate call against "layout". type Renderer struct { pages map[string]*template.Template } // PageData is the view model passed to every admin console page. Title is the // document title; ActiveNav marks the highlighted navigation entry; Data carries // the page-specific payload (one of the *View types in views.go). type PageData struct { Title string ActiveNav string Data any } // NewRenderer parses the embedded layout and every content page under // templates/pages. 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. 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 // renders into an intermediate buffer first, so a mid-render failure never emits // a partial document. It returns an error for an unknown page or a failed render. 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") }