chore: fixed template issue across go apps 8c324a1d
Steve · 2026-05-16 21:19 14 file(s) · +460 −63
apps/easel-go/app.go +11 −11
13 13
var appFS embed.FS
14 14
15 15
type App struct {
16 -
	DB                *sql.DB
17 -
	Log               *slog.Logger
18 -
	Templates         *template.Template
19 -
	HTTP              *http.Client
20 -
	TZ                *time.Location
21 -
	TZName            string
22 -
	Classifications   []string
23 -
	ExcludeTerms      []string
24 -
	BackfillDays      int
25 -
	MaxDedupRetries   int
26 -
	BaseURL           string
16 +
	DB              *sql.DB
17 +
	Log             *slog.Logger
18 +
	Templates       map[string]*template.Template
19 +
	HTTP            *http.Client
20 +
	TZ              *time.Location
21 +
	TZName          string
22 +
	Classifications []string
23 +
	ExcludeTerms    []string
24 +
	BackfillDays    int
25 +
	MaxDedupRetries int
26 +
	BaseURL         string
27 27
}
28 28
29 29
type artworkView struct {
apps/easel-go/handlers.go +8 −14
3 3
import (
4 4
	"html/template"
5 5
	"net/http"
6 -
7 -
	"github.com/stevedylandev/andromeda/crates-go/web"
8 6
)
9 7
10 8
func iiifURL(imageID string) string {
61 59
	d, err := getDaily(a.DB, today)
62 60
	if err != nil {
63 61
		a.Log.Error("index db error", "err", err)
64 -
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Error", Message: "Could not load today's artwork."}, a.Log)
62 +
		a.renderPageStatus(w, http.StatusInternalServerError, "error.html", errorPageData{Title: "Error", Message: "Could not load today's artwork."})
65 63
		return
66 64
	}
67 65
	data := indexPageData{TodayDate: today}
69 67
		v := toArtworkView(*d)
70 68
		data.Artwork = &v
71 69
	}
72 -
	web.Render(a.Templates, w, "index.html", data, a.Log)
70 +
	a.renderPage(w, "index.html", data)
73 71
}
74 72
75 73
func (a *App) dayHandler(w http.ResponseWriter, r *http.Request) {
76 74
	date := r.PathValue("date")
77 75
	if _, ok := parseDate(date); !ok {
78 -
		w.WriteHeader(http.StatusBadRequest)
79 -
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Invalid date", Message: "'" + date + "' is not a valid YYYY-MM-DD date."}, a.Log)
76 +
		a.renderPageStatus(w, http.StatusBadRequest, "error.html", errorPageData{Title: "Invalid date", Message: "'" + date + "' is not a valid YYYY-MM-DD date."})
80 77
		return
81 78
	}
82 79
	today := a.todayInTZ()
83 80
	if date > today {
84 -
		w.WriteHeader(http.StatusNotFound)
85 -
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Not yet", Message: date + " is in the future."}, a.Log)
81 +
		a.renderPageStatus(w, http.StatusNotFound, "error.html", errorPageData{Title: "Not yet", Message: date + " is in the future."})
86 82
		return
87 83
	}
88 84
	d, err := getDaily(a.DB, date)
89 85
	if err != nil {
90 86
		a.Log.Error("day db error", "err", err)
91 -
		w.WriteHeader(http.StatusInternalServerError)
92 -
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Error", Message: "Database error."}, a.Log)
87 +
		a.renderPageStatus(w, http.StatusInternalServerError, "error.html", errorPageData{Title: "Error", Message: "Database error."})
93 88
		return
94 89
	}
95 90
	if d == nil {
96 -
		w.WriteHeader(http.StatusNotFound)
97 -
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Not found", Message: "No artwork stored for " + date + "."}, a.Log)
91 +
		a.renderPageStatus(w, http.StatusNotFound, "error.html", errorPageData{Title: "Not found", Message: "No artwork stored for " + date + "."})
98 92
		return
99 93
	}
100 -
	web.Render(a.Templates, w, "day.html", dayPageData{Date: date, Artwork: toArtworkView(*d)}, a.Log)
94 +
	a.renderPage(w, "day.html", dayPageData{Date: date, Artwork: toArtworkView(*d)})
101 95
}
102 96
103 97
func (a *App) archiveHandler(w http.ResponseWriter, r *http.Request) {
110 104
		}
111 105
		rows = append(rows, archiveRow{Date: it.Date, Title: it.Title, Artist: artist})
112 106
	}
113 -
	web.Render(a.Templates, w, "archive.html", archivePageData{Archive: rows}, a.Log)
107 +
	a.renderPage(w, "archive.html", archivePageData{Archive: rows})
114 108
}
apps/easel-go/main.go +4 −2
2 2
3 3
import (
4 4
	"context"
5 -
	"html/template"
6 5
	"log"
7 6
	"log/slog"
8 7
	"net/http"
49 48
	}
50 49
	excludeTerms := splitCommaTrim(config.Getenv("EASEL_EXCLUDE_TERMS", "erotic,erotica,shunga"))
51 50
52 -
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
51 +
	tmpl, err := buildTemplates()
52 +
	if err != nil {
53 +
		log.Fatal(err)
54 +
	}
53 55
	app := &App{
54 56
		DB:              db,
55 57
		Log:             logger,
apps/easel-go/render.go (added) +55 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"fmt"
6 +
	"html/template"
7 +
	"io/fs"
8 +
	"net/http"
9 +
	"path"
10 +
	"strings"
11 +
)
12 +
13 +
func buildTemplates() (map[string]*template.Template, error) {
14 +
	pages, err := fs.Glob(appFS, "templates/*.html")
15 +
	if err != nil {
16 +
		return nil, err
17 +
	}
18 +
19 +
	out := make(map[string]*template.Template, len(pages))
20 +
	for _, page := range pages {
21 +
		if strings.HasSuffix(page, "/base.html") {
22 +
			continue
23 +
		}
24 +
		tmpl, err := template.ParseFS(appFS, "templates/base.html", page)
25 +
		if err != nil {
26 +
			return nil, fmt.Errorf("parse %s: %w", page, err)
27 +
		}
28 +
		out[path.Base(page)] = tmpl
29 +
	}
30 +
	return out, nil
31 +
}
32 +
33 +
func (a *App) renderPage(w http.ResponseWriter, name string, data any) {
34 +
	a.renderPageStatus(w, http.StatusOK, name, data)
35 +
}
36 +
37 +
func (a *App) renderPageStatus(w http.ResponseWriter, status int, name string, data any) {
38 +
	tmpl, ok := a.Templates[name]
39 +
	if !ok {
40 +
		a.Log.Error("template missing", "name", name)
41 +
		http.Error(w, "template missing", http.StatusInternalServerError)
42 +
		return
43 +
	}
44 +
45 +
	var buf bytes.Buffer
46 +
	if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
47 +
		a.Log.Error("template render failed", "name", name, "err", err)
48 +
		http.Error(w, "template error", http.StatusInternalServerError)
49 +
		return
50 +
	}
51 +
52 +
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
53 +
	w.WriteHeader(status)
54 +
	_, _ = w.Write(buf.Bytes())
55 +
}
apps/feeds-go/feeds_test.go (added) +251 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"io"
6 +
	"log/slog"
7 +
	"net/http"
8 +
	"net/http/httptest"
9 +
	"strings"
10 +
	"testing"
11 +
12 +
	sharedsqlite "github.com/stevedylandev/andromeda/crates-go/sqlite"
13 +
)
14 +
15 +
func newTestDB(t *testing.T) *sql.DB {
16 +
	t.Helper()
17 +
	db, err := sharedsqlite.Open("file::memory:?cache=shared", feedsSchema)
18 +
	if err != nil {
19 +
		t.Fatal(err)
20 +
	}
21 +
	t.Cleanup(func() { _ = db.Close() })
22 +
	return db
23 +
}
24 +
25 +
func newTestApp(t *testing.T) *App {
26 +
	t.Helper()
27 +
	return &App{
28 +
		DB:                 newTestDB(t),
29 +
		Log:                slog.New(slog.NewTextHandler(io.Discard, nil)),
30 +
		DefaultPollMinutes: 30,
31 +
		ItemCap:            2,
32 +
	}
33 +
}
34 +
35 +
func seedSubscriptionForTest(t *testing.T, db *sql.DB, feedURL, title string, categoryID *int64) *Subscription {
36 +
	t.Helper()
37 +
	sub, err := insertSubscription(db, feedURL, title, nil, categoryID)
38 +
	if err != nil {
39 +
		t.Fatal(err)
40 +
	}
41 +
	return sub
42 +
}
43 +
44 +
func TestParseOPMLHandlesNestedCategories(t *testing.T) {
45 +
	content := `<?xml version="1.0" encoding="UTF-8"?>
46 +
<opml version="2.0">
47 +
  <body>
48 +
    <outline text="Tech">
49 +
      <outline text="Go Blog" xmlUrl="https://go.dev/feed.xml" htmlUrl="https://go.dev/blog/" />
50 +
      <outline text="News">
51 +
        <outline title="Hacker News" xmlUrl="https://hnrss.org/frontpage" htmlUrl="https://news.ycombinator.com/" />
52 +
      </outline>
53 +
    </outline>
54 +
    <outline text="Standalone" xmlUrl="https://example.com/rss.xml" />
55 +
  </body>
56 +
</opml>`
57 +
58 +
	got := parseOPML(content)
59 +
	if len(got) != 3 {
60 +
		t.Fatalf("expected 3 entries, got %d", len(got))
61 +
	}
62 +
	if got[0].Category != "Tech" || got[0].Title != "Go Blog" {
63 +
		t.Fatalf("unexpected first entry: %+v", got[0])
64 +
	}
65 +
	if got[1].Category != "News" || got[1].Title != "Hacker News" {
66 +
		t.Fatalf("unexpected nested entry: %+v", got[1])
67 +
	}
68 +
	if got[2].Category != "" || got[2].Title != "Standalone" {
69 +
		t.Fatalf("unexpected standalone entry: %+v", got[2])
70 +
	}
71 +
}
72 +
73 +
func TestParseOPMLInvalidReturnsNil(t *testing.T) {
74 +
	if got := parseOPML("<opml><body>"); got != nil {
75 +
		t.Fatalf("expected nil for invalid OPML, got %+v", got)
76 +
	}
77 +
}
78 +
79 +
func TestDeriveTitleFromHTMLStripsMarkupAndTruncates(t *testing.T) {
80 +
	src := `<p>Hello <strong>world</strong> &amp; friends.</p>`
81 +
	if got := deriveTitleFromHTML(src); got != "Hello world & friends." {
82 +
		t.Fatalf("unexpected title: %q", got)
83 +
	}
84 +
85 +
	long := strings.Repeat("word ", 30)
86 +
	got := deriveTitleFromHTML("<div>" + long + "</div>")
87 +
	if !strings.HasSuffix(got, "…") {
88 +
		t.Fatalf("expected ellipsis, got %q", got)
89 +
	}
90 +
}
91 +
92 +
func TestFindAlternateFeedLinksAndFavicon(t *testing.T) {
93 +
	doc := `
94 +
<html><head>
95 +
  <link rel="alternate" type="application/rss+xml" href="/rss.xml">
96 +
  <link rel="icon" type="image/png" href="/favicon.png">
97 +
  <link rel="alternate stylesheet" type="application/atom+xml" href="https://example.com/atom.xml">
98 +
</head></html>`
99 +
100 +
	links := findAlternateFeedLinks(doc)
101 +
	if len(links) != 2 {
102 +
		t.Fatalf("expected 2 feed links, got %d (%v)", len(links), links)
103 +
	}
104 +
	if links[0] != "/rss.xml" || links[1] != "https://example.com/atom.xml" {
105 +
		t.Fatalf("unexpected links: %v", links)
106 +
	}
107 +
	if href := findLinkHref(doc, func(rel, typ string) bool { return strings.Contains(strings.ToLower(rel), "icon") }); href != "/favicon.png" {
108 +
		t.Fatalf("unexpected favicon href: %q", href)
109 +
	}
110 +
}
111 +
112 +
func TestItemFilterFromRequestParsesValues(t *testing.T) {
113 +
	req := httptest.NewRequest(http.MethodGet, "/?limit=25&unread=true&category_id=5&subscription_id=8", nil)
114 +
	filter := itemFilterFromRequest(req)
115 +
	if filter.Limit != 25 || !filter.UnreadOnly {
116 +
		t.Fatalf("unexpected base filter: %+v", filter)
117 +
	}
118 +
	if filter.CategoryID == nil || *filter.CategoryID != 5 {
119 +
		t.Fatalf("unexpected category id: %+v", filter.CategoryID)
120 +
	}
121 +
	if filter.SubscriptionID == nil || *filter.SubscriptionID != 8 {
122 +
		t.Fatalf("unexpected subscription id: %+v", filter.SubscriptionID)
123 +
	}
124 +
}
125 +
126 +
func TestFormPollMinutesValidation(t *testing.T) {
127 +
	good := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("poll_interval_minutes=60"))
128 +
	good.Header.Set("Content-Type", "application/x-www-form-urlencoded")
129 +
	if mins, ok := formPollMinutes(good); !ok || mins != 60 {
130 +
		t.Fatalf("expected valid poll minutes, got %d %v", mins, ok)
131 +
	}
132 +
133 +
	bad := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("poll_interval_minutes=0"))
134 +
	bad.Header.Set("Content-Type", "application/x-www-form-urlencoded")
135 +
	if _, ok := formPollMinutes(bad); ok {
136 +
		t.Fatal("expected invalid poll minutes")
137 +
	}
138 +
}
139 +
140 +
func TestWithCORSHandlesOptions(t *testing.T) {
141 +
	app := &App{}
142 +
	called := false
143 +
	h := app.withCORS(func(w http.ResponseWriter, r *http.Request) {
144 +
		called = true
145 +
		w.WriteHeader(http.StatusCreated)
146 +
	})
147 +
148 +
	rec := httptest.NewRecorder()
149 +
	h(rec, httptest.NewRequest(http.MethodOptions, "/api/items", nil))
150 +
	if called {
151 +
		t.Fatal("handler should not be called for OPTIONS")
152 +
	}
153 +
	if rec.Code != http.StatusNoContent {
154 +
		t.Fatalf("expected 204, got %d", rec.Code)
155 +
	}
156 +
	if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
157 +
		t.Fatalf("missing CORS header: %v", rec.Header())
158 +
	}
159 +
}
160 +
161 +
func TestGetOrCreateCategoryTrimsAndReuses(t *testing.T) {
162 +
	db := newTestDB(t)
163 +
	first, err := getOrCreateCategory(db, "  News  ")
164 +
	if err != nil {
165 +
		t.Fatal(err)
166 +
	}
167 +
	second, err := getOrCreateCategory(db, "News")
168 +
	if err != nil {
169 +
		t.Fatal(err)
170 +
	}
171 +
	if first == nil || second == nil || first.ID != second.ID {
172 +
		t.Fatalf("expected same category, got first=%+v second=%+v", first, second)
173 +
	}
174 +
}
175 +
176 +
func TestListItemsAndPruneSubscription(t *testing.T) {
177 +
	app := newTestApp(t)
178 +
	cat, err := getOrCreateCategory(app.DB, "Tech")
179 +
	if err != nil {
180 +
		t.Fatal(err)
181 +
	}
182 +
	sub := seedSubscriptionForTest(t, app.DB, "https://example.com/feed.xml", "Example Feed", &cat.ID)
183 +
184 +
	items := []NewItem{
185 +
		{SubscriptionID: sub.ID, GUID: "1", Title: "Old", Link: "https://example.com/1", Author: "Ron", PublishedAt: 10},
186 +
		{SubscriptionID: sub.ID, GUID: "2", Title: "Mid", Link: "https://example.com/2", Author: "", PublishedAt: 20},
187 +
		{SubscriptionID: sub.ID, GUID: "3", Title: "New", Link: "https://example.com/3", Author: "Leslie", PublishedAt: 30},
188 +
	}
189 +
	for _, item := range items {
190 +
		ok, err := insertItemIgnoreDup(app.DB, item)
191 +
		if err != nil || !ok {
192 +
			t.Fatalf("insert failed for %+v: ok=%v err=%v", item, ok, err)
193 +
		}
194 +
	}
195 +
	if ok, err := insertItemIgnoreDup(app.DB, items[0]); err != nil || ok {
196 +
		t.Fatalf("expected duplicate insert to be ignored, ok=%v err=%v", ok, err)
197 +
	}
198 +
	if _, err := markItemRead(app.DB, 1, true); err != nil {
199 +
		t.Fatal(err)
200 +
	}
201 +
	if err := pruneSubscription(app.DB, sub.ID, 2); err != nil {
202 +
		t.Fatal(err)
203 +
	}
204 +
205 +
	listed, err := listItems(app.DB, ListItemsFilter{Limit: 10})
206 +
	if err != nil {
207 +
		t.Fatal(err)
208 +
	}
209 +
	if len(listed) != 2 {
210 +
		t.Fatalf("expected 2 items after prune, got %d", len(listed))
211 +
	}
212 +
	if listed[0].Title != "New" || listed[1].Title != "Mid" {
213 +
		t.Fatalf("unexpected order after prune: %+v", listed)
214 +
	}
215 +
	if listed[0].Author == nil || *listed[0].Author != "Leslie" {
216 +
		t.Fatalf("expected author pointer on newest item, got %+v", listed[0].Author)
217 +
	}
218 +
	if listed[1].Author != nil {
219 +
		t.Fatalf("expected nil author on blank author item, got %+v", listed[1].Author)
220 +
	}
221 +
	if listed[0].CategoryName == nil || *listed[0].CategoryName != "Tech" {
222 +
		t.Fatalf("expected category name, got %+v", listed[0].CategoryName)
223 +
	}
224 +
225 +
	filtered, err := listItems(app.DB, ListItemsFilter{Limit: 10, UnreadOnly: true})
226 +
	if err != nil {
227 +
		t.Fatal(err)
228 +
	}
229 +
	if len(filtered) != 2 {
230 +
		t.Fatalf("expected both remaining items to be unread, got %d", len(filtered))
231 +
	}
232 +
}
233 +
234 +
func TestPollIntervalMinutesUsesFallbackForMissingOrInvalidSetting(t *testing.T) {
235 +
	app := newTestApp(t)
236 +
	if got := app.pollIntervalMinutes(); got != 30 {
237 +
		t.Fatalf("expected default poll interval, got %d", got)
238 +
	}
239 +
	if err := setSetting(app.DB, "poll_interval_minutes", "45"); err != nil {
240 +
		t.Fatal(err)
241 +
	}
242 +
	if got := app.pollIntervalMinutes(); got != 45 {
243 +
		t.Fatalf("expected stored poll interval, got %d", got)
244 +
	}
245 +
	if err := setSetting(app.DB, "poll_interval_minutes", "nonsense"); err != nil {
246 +
		t.Fatal(err)
247 +
	}
248 +
	if got := app.pollIntervalMinutes(); got != 30 {
249 +
		t.Fatalf("expected fallback for invalid setting, got %d", got)
250 +
	}
251 +
}
apps/og-go/app.go +1 −1
11 11
12 12
type App struct {
13 13
	Log       *slog.Logger
14 -
	Templates *template.Template
14 +
	Templates map[string]*template.Template
15 15
}
16 16
17 17
type tagKV struct {
apps/og-go/handlers.go +5 −7
4 4
	"net/http"
5 5
	"slices"
6 6
	"strings"
7 -
8 -
	"github.com/stevedylandev/andromeda/crates-go/web"
9 7
)
10 8
11 9
var commonTags = []string{"og:title", "og:description", "og:image", "og:url", "og:type"}
12 10
13 11
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
14 -
	web.Render(a.Templates, w, "index.html", nil, a.Log)
12 +
	a.renderPage(w, "index.html", nil)
15 13
}
16 14
17 15
func (a *App) checkHandler(w http.ResponseWriter, r *http.Request) {
18 16
	if err := r.ParseForm(); err != nil {
19 -
		web.Render(a.Templates, w, "results.html", resultsData{Error: "Bad request"}, a.Log)
17 +
		a.renderPage(w, "results.html", resultsData{Error: "Bad request"})
20 18
		return
21 19
	}
22 20
	u := strings.TrimSpace(r.FormValue("url"))
23 21
	if u == "" {
24 -
		web.Render(a.Templates, w, "results.html", resultsData{Error: "Please enter a URL"}, a.Log)
22 +
		a.renderPage(w, "results.html", resultsData{Error: "Please enter a URL"})
25 23
		return
26 24
	}
27 25
	if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
30 28
31 29
	res, err := fetchOGData(r.Context(), u)
32 30
	if err != nil {
33 -
		web.Render(a.Templates, w, "results.html", resultsData{URL: u, Error: err.Error()}, a.Log)
31 +
		a.renderPage(w, "results.html", resultsData{URL: u, Error: err.Error()})
34 32
		return
35 33
	}
36 34
49 47
		data.FoundTags = append(data.FoundTags, tagKV{Key: key, Value: res.OGTags[key]})
50 48
	}
51 49
	data.LinkTags = res.LinkTags
52 -
	web.Render(a.Templates, w, "results.html", data, a.Log)
50 +
	a.renderPage(w, "results.html", data)
53 51
}
apps/og-go/main.go +4 −2
1 1
package main
2 2
3 3
import (
4 -
	"html/template"
5 4
	"log"
6 5
	"log/slog"
7 6
	"net/http"
13 12
func main() {
14 13
	config.LoadDotEnv(".env")
15 14
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
16 -
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
15 +
	tmpl, err := buildTemplates()
16 +
	if err != nil {
17 +
		log.Fatal(err)
18 +
	}
17 19
	app := &App{Log: logger, Templates: tmpl}
18 20
19 21
	addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000")
apps/og-go/render.go (added) +42 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"html/template"
6 +
	"io/fs"
7 +
	"net/http"
8 +
	"path"
9 +
	"strings"
10 +
11 +
	"github.com/stevedylandev/andromeda/crates-go/web"
12 +
)
13 +
14 +
func buildTemplates() (map[string]*template.Template, error) {
15 +
	pages, err := fs.Glob(appFS, "templates/*.html")
16 +
	if err != nil {
17 +
		return nil, err
18 +
	}
19 +
20 +
	out := make(map[string]*template.Template, len(pages))
21 +
	for _, page := range pages {
22 +
		if strings.HasSuffix(page, "/base.html") {
23 +
			continue
24 +
		}
25 +
		tmpl, err := template.ParseFS(appFS, "templates/base.html", page)
26 +
		if err != nil {
27 +
			return nil, fmt.Errorf("parse %s: %w", page, err)
28 +
		}
29 +
		out[path.Base(page)] = tmpl
30 +
	}
31 +
	return out, nil
32 +
}
33 +
34 +
func (a *App) renderPage(w http.ResponseWriter, name string, data any) {
35 +
	tmpl, ok := a.Templates[name]
36 +
	if !ok {
37 +
		a.Log.Error("template missing", "name", name)
38 +
		http.Error(w, "template missing", http.StatusInternalServerError)
39 +
		return
40 +
	}
41 +
	web.Render(tmpl, w, name, data, a.Log)
42 +
}
apps/posts-go/app.go +1 −1
17 17
type App struct {
18 18
	DB           *sql.DB
19 19
	Log          *slog.Logger
20 -
	Templates    *template.Template
20 +
	Templates    map[string]*template.Template
21 21
	Sessions     *auth.Store
22 22
	AppPassword  string
23 23
	CookieSecure bool
apps/posts-go/handlers_admin.go +12 −13
10 10
	"strings"
11 11
12 12
	"github.com/stevedylandev/andromeda/crates-go/auth"
13 -
	"github.com/stevedylandev/andromeda/crates-go/web"
14 13
)
15 14
16 15
const importMaxBytes = 50 * 1024 * 1024
18 17
const bodyLimit = 51 * 1024 * 1024
19 18
20 19
func (a *App) loginGet(w http.ResponseWriter, r *http.Request) {
21 -
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
20 +
	a.renderPage(w, "login.html", loginPageData{Error: r.URL.Query().Get("error")})
22 21
}
23 22
24 23
func (a *App) loginPost(w http.ResponseWriter, r *http.Request) {
55 54
		http.Error(w, "Server error", http.StatusInternalServerError)
56 55
		return
57 56
	}
58 -
	web.Render(a.Templates, w, "admin_index.html", adminIndexPageData{Posts: posts}, a.Log)
57 +
	a.renderPage(w, "admin_index.html", adminIndexPageData{Posts: posts})
59 58
}
60 59
61 60
func (a *App) adminNewPost(w http.ResponseWriter, r *http.Request) {
62 -
	web.Render(a.Templates, w, "admin_post_form.html", adminPostFormPageData{Error: r.URL.Query().Get("error")}, a.Log)
61 +
	a.renderPage(w, "admin_post_form.html", adminPostFormPageData{Error: r.URL.Query().Get("error")})
63 62
}
64 63
65 64
func (a *App) adminCreatePost(w http.ResponseWriter, r *http.Request) {
120 119
		http.Error(w, "Post not found", http.StatusNotFound)
121 120
		return
122 121
	}
123 -
	web.Render(a.Templates, w, "admin_post_form.html", adminPostFormPageData{Post: post, Error: r.URL.Query().Get("error")}, a.Log)
122 +
	a.renderPage(w, "admin_post_form.html", adminPostFormPageData{Post: post, Error: r.URL.Query().Get("error")})
124 123
}
125 124
126 125
func (a *App) adminUpdatePost(w http.ResponseWriter, r *http.Request) {
176 175
		http.Error(w, "Server error", http.StatusInternalServerError)
177 176
		return
178 177
	}
179 -
	web.Render(a.Templates, w, "admin_pages.html", adminPagesPageData{Pages: pages}, a.Log)
178 +
	a.renderPage(w, "admin_pages.html", adminPagesPageData{Pages: pages})
180 179
}
181 180
182 181
func (a *App) adminNewPage(w http.ResponseWriter, r *http.Request) {
183 -
	web.Render(a.Templates, w, "admin_page_form.html", adminPageFormPageData{Error: r.URL.Query().Get("error")}, a.Log)
182 +
	a.renderPage(w, "admin_page_form.html", adminPageFormPageData{Error: r.URL.Query().Get("error")})
184 183
}
185 184
186 185
func (a *App) adminCreatePage(w http.ResponseWriter, r *http.Request) {
217 216
		http.Error(w, "Page not found", http.StatusNotFound)
218 217
		return
219 218
	}
220 -
	web.Render(a.Templates, w, "admin_page_form.html", adminPageFormPageData{Page: page, Error: r.URL.Query().Get("error")}, a.Log)
219 +
	a.renderPage(w, "admin_page_form.html", adminPageFormPageData{Page: page, Error: r.URL.Query().Get("error")})
221 220
}
222 221
223 222
func (a *App) adminUpdatePage(w http.ResponseWriter, r *http.Request) {
253 252
func (a *App) adminGetSettings(w http.ResponseWriter, r *http.Request) {
254 253
	get := func(k string) string { v, _ := getSetting(a.DB, k); return v }
255 254
	defaultCSS, _ := appFS.ReadFile("static/styles.css")
256 -
	web.Render(a.Templates, w, "admin_settings.html", adminSettingsPageData{
255 +
	a.renderPage(w, "admin_settings.html", adminSettingsPageData{
257 256
		BlogTitle:       get("blog_title"),
258 257
		BlogDescription: get("blog_description"),
259 258
		IntroContent:    get("intro_content"),
265 264
		CustomHeader:    get("custom_header"),
266 265
		CustomFooter:    get("custom_footer"),
267 266
		Success:         r.URL.Query().Get("success") == "true",
268 -
	}, a.Log)
267 +
	})
269 268
}
270 269
271 270
func (a *App) adminPostSettings(w http.ResponseWriter, r *http.Request) {
291 290
		http.Error(w, "Server error", http.StatusInternalServerError)
292 291
		return
293 292
	}
294 -
	web.Render(a.Templates, w, "admin_files.html", adminFilesPageData{
293 +
	a.renderPage(w, "admin_files.html", adminFilesPageData{
295 294
		Files: files, SiteURL: a.SiteURL,
296 295
		Error:   r.URL.Query().Get("error"),
297 296
		Success: r.URL.Query().Get("success") == "true",
298 -
	}, a.Log)
297 +
	})
299 298
}
300 299
301 300
func (a *App) adminUploadFile(w http.ResponseWriter, r *http.Request) {
435 434
		_, _ = fmt.Sscanf(v, "%d", &n)
436 435
		data.Skipped = &n
437 436
	}
438 -
	web.Render(a.Templates, w, "admin_import.html", data, a.Log)
437 +
	a.renderPage(w, "admin_import.html", data)
439 438
}
440 439
441 440
func (a *App) adminImportPosts(w http.ResponseWriter, r *http.Request) {
apps/posts-go/handlers_public.go +8 −10
4 4
	"html/template"
5 5
	"net/http"
6 6
	"strings"
7 -
8 -
	"github.com/stevedylandev/andromeda/crates-go/web"
9 7
)
10 8
11 9
func (a *App) site() siteContext {
77 75
		introHTML = strings.ReplaceAll(introHTML, "{{latest_posts}}", embed)
78 76
	}
79 77
80 -
	web.Render(a.Templates, w, "index.html", indexPageData{
78 +
	a.renderPage(w, "index.html", indexPageData{
81 79
		BlogTitle: ctx.BlogTitle, BlogDescription: blogDesc,
82 80
		IntroHTML: template.HTML(introHTML),
83 81
		Posts:     posts,
84 82
		NavLinks:  ctx.NavLinks, FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL,
85 83
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML,
86 -
	}, a.Log)
84 +
	})
87 85
}
88 86
89 87
func (a *App) publicPost(w http.ResponseWriter, r *http.Request) {
99 97
	}
100 98
	ctx := a.site()
101 99
	rendered := renderMarkdown(post.Content)
102 -
	web.Render(a.Templates, w, "post.html", postPageData{
100 +
	a.renderPage(w, "post.html", postPageData{
103 101
		BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Post: *post,
104 102
		RenderedContent: template.HTML(rendered),
105 103
		FaviconURL:      ctx.FaviconURL, OGImageURL: ctx.OGImageURL,
106 104
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML,
107 -
	}, a.Log)
105 +
	})
108 106
}
109 107
110 108
func (a *App) publicPage(w http.ResponseWriter, r *http.Request) {
125 123
	}
126 124
	ctx := a.site()
127 125
	rendered := renderMarkdown(page.Content)
128 -
	web.Render(a.Templates, w, "page.html", pagePageData{
126 +
	a.renderPage(w, "page.html", pagePageData{
129 127
		BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Page: *page,
130 128
		RenderedContent: template.HTML(rendered),
131 129
		FaviconURL:      ctx.FaviconURL, OGImageURL: ctx.OGImageURL,
132 130
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML,
133 -
	}, a.Log)
131 +
	})
134 132
}
135 133
136 134
func (a *App) publicPostsList(w http.ResponseWriter, r *http.Request) {
140 138
		http.Error(w, "Server error", http.StatusInternalServerError)
141 139
		return
142 140
	}
143 -
	web.Render(a.Templates, w, "posts.html", postsListPageData{
141 +
	a.renderPage(w, "posts.html", postsListPageData{
144 142
		BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Posts: posts,
145 143
		FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL,
146 144
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML,
147 -
	}, a.Log)
145 +
	})
148 146
}
149 147
150 148
func (a *App) customCSS(w http.ResponseWriter, r *http.Request) {
apps/posts-go/main.go +4 −2
1 1
package main
2 2
3 3
import (
4 -
	"html/template"
5 4
	"log"
6 5
	"log/slog"
7 6
	"net/http"
60 59
	}
61 60
	sessions.PruneExpired()
62 61
63 -
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
62 +
	tmpl, err := buildTemplates()
63 +
	if err != nil {
64 +
		log.Fatal(err)
65 +
	}
64 66
	app := &App{
65 67
		DB:           db,
66 68
		Log:          logger,
apps/posts-go/render.go (added) +54 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"html/template"
6 +
	"io/fs"
7 +
	"net/http"
8 +
	"path"
9 +
	"strings"
10 +
11 +
	"github.com/stevedylandev/andromeda/crates-go/web"
12 +
)
13 +
14 +
func buildTemplates() (map[string]*template.Template, error) {
15 +
	pages, err := fs.Glob(appFS, "templates/*.html")
16 +
	if err != nil {
17 +
		return nil, err
18 +
	}
19 +
20 +
	out := make(map[string]*template.Template, len(pages))
21 +
	for _, page := range pages {
22 +
		name := path.Base(page)
23 +
		if name == "base.html" || name == "admin_base.html" {
24 +
			continue
25 +
		}
26 +
27 +
		patterns := []string{page}
28 +
		switch {
29 +
		case strings.HasPrefix(name, "admin_"):
30 +
			patterns = append([]string{"templates/admin_base.html"}, patterns...)
31 +
		case name == "login.html":
32 +
			// standalone template
33 +
		default:
34 +
			patterns = append([]string{"templates/base.html"}, patterns...)
35 +
		}
36 +
37 +
		tmpl, err := template.ParseFS(appFS, patterns...)
38 +
		if err != nil {
39 +
			return nil, fmt.Errorf("parse %s: %w", page, err)
40 +
		}
41 +
		out[name] = tmpl
42 +
	}
43 +
	return out, nil
44 +
}
45 +
46 +
func (a *App) renderPage(w http.ResponseWriter, name string, data any) {
47 +
	tmpl, ok := a.Templates[name]
48 +
	if !ok {
49 +
		a.Log.Error("template missing", "name", name)
50 +
		http.Error(w, "template missing", http.StatusInternalServerError)
51 +
		return
52 +
	}
53 +
	web.Render(tmpl, w, name, data, a.Log)
54 +
}