···1111)
12121313var (
1414- templates *template.Template
1515- templatesOnce sync.Once
1616- templatesErr error
1414+ templateFuncs template.FuncMap
1515+ funcsOnce sync.Once
1616+ templateDir string
1717+ templateDirMu sync.Once
1718)
18191919-// loadTemplates initializes templates lazily - only when first needed
2020-func loadTemplates() error {
2121- templatesOnce.Do(func() {
2222- // Parse all template files including partials
2323- templates = template.New("")
2424- templates = templates.Funcs(template.FuncMap{
2020+// getTemplateFuncs returns the function map used by all templates
2121+func getTemplateFuncs() template.FuncMap {
2222+ funcsOnce.Do(func() {
2323+ templateFuncs = template.FuncMap{
2524 "formatTemp": FormatTemp,
2625 "formatTime": FormatTime,
2726 "formatRating": FormatRating,
···3534 "iterateRemaining": IterateRemaining,
3635 "hasTemp": HasTemp,
3736 "hasValue": HasValue,
3838- })
3939-4040- // Try to find templates relative to working directory
4141- // This supports both running from project root and from package directory
4242- paths := []string{
4343- "templates/*.tmpl",
4444- "../../templates/*.tmpl", // for when tests run from internal/bff
4545- "../../../templates/*.tmpl", // for deeper test directories
4637 }
3838+ })
3939+ return templateFuncs
4040+}
47414848- var err error
4949- for _, path := range paths {
5050- dir := path[:len(path)-6] // Remove *.tmpl
5151- if _, statErr := os.Stat(dir); statErr == nil {
5252- templates, err = templates.ParseGlob(path)
5353- if err == nil {
5454- break
5555- }
5656- }
4242+// getTemplateDir finds the template directory
4343+func getTemplateDir() string {
4444+ templateDirMu.Do(func() {
4545+ dirs := []string{
4646+ "templates",
4747+ "../../templates",
4848+ "../../../templates",
5749 }
5858- if err != nil {
5959- templatesErr = err
6060- return
6161- }
6262-6363- // Parse partials
6464- partialPaths := []string{
6565- "templates/partials/*.tmpl",
6666- "../../templates/partials/*.tmpl",
6767- "../../../templates/partials/*.tmpl",
6868- }
6969-7070- for _, path := range partialPaths {
7171- dir := path[:len(path)-6]
7272- if _, statErr := os.Stat(dir); statErr == nil {
7373- templates, err = templates.ParseGlob(path)
7474- if err == nil {
7575- break
7676- }
5050+ for _, dir := range dirs {
5151+ if _, err := os.Stat(dir); err == nil {
5252+ templateDir = dir
5353+ return
7754 }
7855 }
7979- if err != nil {
8080- templatesErr = err
8181- }
5656+ templateDir = "templates" // fallback
8257 })
8383- return templatesErr
5858+ return templateDir
5959+}
6060+6161+// parsePageTemplate parses a complete page template with layout and partials
6262+func parsePageTemplate(pageName string) (*template.Template, error) {
6363+ dir := getTemplateDir()
6464+ t := template.New("").Funcs(getTemplateFuncs())
6565+6666+ // Parse layout first
6767+ t, err := t.ParseFiles(dir + "/layout.tmpl")
6868+ if err != nil {
6969+ return nil, err
7070+ }
7171+7272+ // Parse all partials
7373+ t, err = t.ParseGlob(dir + "/partials/*.tmpl")
7474+ if err != nil {
7575+ return nil, err
7676+ }
7777+7878+ // Parse the specific page template
7979+ t, err = t.ParseFiles(dir + "/" + pageName)
8080+ if err != nil {
8181+ return nil, err
8282+ }
8383+8484+ return t, nil
8585+}
8686+8787+// parsePartialTemplate parses just the partials (for partial-only renders)
8888+func parsePartialTemplate() (*template.Template, error) {
8989+ dir := getTemplateDir()
9090+ t := template.New("").Funcs(getTemplateFuncs())
9191+9292+ // Parse all partials
9393+ t, err := t.ParseGlob(dir + "/partials/*.tmpl")
9494+ if err != nil {
9595+ return nil, err
9696+ }
9797+9898+ return t, nil
8499}
8510086101// PageData contains data for rendering pages
···113128114129// RenderTemplate renders a template with layout
115130func RenderTemplate(w http.ResponseWriter, tmpl string, data *PageData) error {
116116- if err := loadTemplates(); err != nil {
131131+ t, err := parsePageTemplate(tmpl)
132132+ if err != nil {
117133 return err
118134 }
119119- // Execute the layout template which calls the content template
120120- return templates.ExecuteTemplate(w, "layout", data)
135135+ return t.ExecuteTemplate(w, "layout", data)
121136}
122137123138// RenderHome renders the home page
124139func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, feedItems []*feed.FeedItem) error {
125125- if err := loadTemplates(); err != nil {
140140+ t, err := parsePageTemplate("home.tmpl")
141141+ if err != nil {
126142 return err
127143 }
128144 data := &PageData{
···131147 UserDID: userDID,
132148 FeedItems: feedItems,
133149 }
134134- // Need to execute layout with the home template
135135- t := template.Must(templates.Clone())
136136- t = template.Must(t.ParseFiles(findTemplatePath("home.tmpl")))
137150 return t.ExecuteTemplate(w, "layout", data)
138151}
139152140153// RenderBrewList renders the brew list page
141154func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated bool, userDID string) error {
142142- if err := loadTemplates(); err != nil {
155155+ t, err := parsePageTemplate("brew_list.tmpl")
156156+ if err != nil {
143157 return err
144158 }
145159 brewList := make([]*BrewListData, len(brews))
···158172 IsAuthenticated: isAuthenticated,
159173 UserDID: userDID,
160174 }
161161- t := template.Must(templates.Clone())
162162- t = template.Must(t.ParseFiles(findTemplatePath("brew_list.tmpl")))
163175 return t.ExecuteTemplate(w, "layout", data)
164176}
165177166178// RenderBrewForm renders the brew form page
167179func RenderBrewForm(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, brew *models.Brew, isAuthenticated bool, userDID string) error {
168168- if err := loadTemplates(); err != nil {
180180+ t, err := parsePageTemplate("brew_form.tmpl")
181181+ if err != nil {
169182 return err
170183 }
171184 var brewData *BrewData
···189202 IsAuthenticated: isAuthenticated,
190203 UserDID: userDID,
191204 }
192192- t := template.Must(templates.Clone())
193193- t = template.Must(t.ParseFiles(findTemplatePath("brew_form.tmpl")))
194205 return t.ExecuteTemplate(w, "layout", data)
195206}
196207197208// RenderManage renders the manage page
198209func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string) error {
199199- if err := loadTemplates(); err != nil {
210210+ t, err := parsePageTemplate("manage.tmpl")
211211+ if err != nil {
200212 return err
201213 }
202214 data := &PageData{
···208220 IsAuthenticated: isAuthenticated,
209221 UserDID: userDID,
210222 }
211211- t := template.Must(templates.Clone())
212212- t = template.Must(t.ParseFiles(findTemplatePath("manage.tmpl")))
213223 return t.ExecuteTemplate(w, "layout", data)
214224}
215225226226+// RenderFeedPartial renders just the feed partial (for HTMX async loading)
227227+func RenderFeedPartial(w http.ResponseWriter, feedItems []*feed.FeedItem) error {
228228+ t, err := parsePartialTemplate()
229229+ if err != nil {
230230+ return err
231231+ }
232232+ data := &PageData{
233233+ FeedItems: feedItems,
234234+ }
235235+ return t.ExecuteTemplate(w, "feed", data)
236236+}
237237+216238// findTemplatePath finds the correct path to a template file
217239func findTemplatePath(name string) string {
218218- paths := []string{
219219- "templates/" + name,
220220- "../../templates/" + name,
221221- "../../../templates/" + name,
222222- }
223223- for _, path := range paths {
224224- if _, err := os.Stat(path); err == nil {
225225- return path
226226- }
227227- }
228228- // Return the default path even if it doesn't exist - will fail at parse time
229229- return "templates/" + name
240240+ dir := getTemplateDir()
241241+ return dir + "/" + name
230242}
+9-2
internal/handlers/handlers.go
···8888 didStr, err := atproto.GetAuthenticatedDID(r.Context())
8989 isAuthenticated := err == nil && didStr != ""
90909191- // Fetch feed items (if feed service is configured)
9191+ // Don't fetch feed items here - let them load async via HTMX
9292+ if err := bff.RenderHome(w, isAuthenticated, didStr, nil); err != nil {
9393+ http.Error(w, err.Error(), http.StatusInternalServerError)
9494+ }
9595+}
9696+9797+// Community feed partial (loaded async via HTMX)
9898+func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) {
9299 var feedItems []*feed.FeedItem
93100 if h.feedService != nil {
94101 feedItems, _ = h.feedService.GetRecentBrews(r.Context(), 20)
95102 }
961039797- if err := bff.RenderHome(w, isAuthenticated, didStr, feedItems); err != nil {
104104+ if err := bff.RenderFeedPartial(w, feedItems); err != nil {
98105 http.Error(w, err.Error(), http.StatusInternalServerError)
99106 }
100107}
+3
internal/routing/routing.go
···3737 // API route for fetching all user data (used by client-side cache)
3838 mux.HandleFunc("GET /api/data", h.HandleAPIListAll)
39394040+ // Community feed partial (loaded async via HTMX)
4141+ mux.HandleFunc("GET /api/feed", h.HandleFeedPartial)
4242+4043 // Page routes (must come before static files)
4144 mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match
4245 mux.HandleFunc("GET /manage", h.HandleManage)