orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "fmt"
5 "html/template"
6 "net/http"
7 "strings"
8
9 "isaacbythewood.com/web"
10)
11
12// PageData is everything base.html and one page template need.
13type PageData struct {
14 Title string
15 Description string
16 Canonical string
17 ThemeColor string
18
19 OGImage string
20 JSONLD template.JS
21 Page string
22 Num string
23 Staging bool
24 Analytics bool
25 LoaderWaits bool
26 GridArea template.CSS
27
28 Script string
29 Styles []string
30 NavPages []NavPage
31 TopLinks []TopLink
32
33 MenuSrc string
34 MenuSrcset template.Srcset
35 AvatarSrc string
36
37 HeroSrc string
38 HeroSrcset template.Srcset
39
40 Latest *LatestPost
41 Words []AboutWord
42 Live []SiteView
43 Active []ProjectView
44 Archived []ProjectView
45 Pours []PourView
46 Generative []Generative
47 Methods []ContactMethod
48}
49
50// AboutWord carries its slot as an inline style, because html/template refuses
51// to interpolate an untyped string into a style attribute.
52type AboutWord struct {
53 Text string
54 Style template.CSS
55}
56
57type SiteView struct {
58 Site
59 Source string
60 Commit string
61 Delay template.CSS
62}
63
64type ProjectView struct {
65 Project
66 URL string
67 Commit string
68 Delay template.CSS
69}
70
71// PourView carries a whole size ladder rather than one path, so a phone
72// rendering a card at 380px does not download the widest scan.
73type PourView struct {
74 Pour
75 CardSrc string // fallback for browsers without srcset
76 CardSrcset template.Srcset
77 LightboxSrc string
78}
79
80type site struct {
81 renderer *web.Renderer
82 commits *CommitCache
83 latest *LatestCache
84 // Resolves Next.js era /_next/image requests onto the built files.
85 nextImages *nextImageIndex
86 script string
87 styles []string
88}
89
90func (s *site) page(name, title, description string) PageData {
91 fullTitle := siteTitle
92 if title != "" {
93 fullTitle = title + " — " + siteTitle
94 }
95 if description == "" {
96 description = siteDesc
97 }
98
99 num := "000"
100 href := "/" + name
101 if name == "index" {
102 href = "/"
103 }
104 for _, p := range navPages {
105 if p.Href == href {
106 num = p.Num
107 }
108 }
109
110 canonical := baseURL
111 var crumbs []NavPage
112 if href != "/" {
113 canonical = baseURL + href
114 for _, p := range navPages {
115 if p.Href == href {
116 crumbs = append(crumbs, p)
117 }
118 }
119 }
120
121 return PageData{
122 // Always set, or a <main> with no grid-area auto-places into the first
123 // cell, which is the 60px sidebar gutter.
124 GridArea: template.CSS("main"),
125 Title: fullTitle,
126 Description: description,
127 Canonical: canonical,
128 ThemeColor: themeColor,
129 Page: name,
130 Num: num,
131 Staging: Staging,
132 // Off on the test hostname, so it does not report into the real ID.
133 Analytics: !Staging,
134 Script: s.script,
135 Styles: s.styles,
136 NavPages: navPages,
137 TopLinks: topLinks,
138 // The menu panel is 40vw wide but full height, and cover on a 4:3
139 // source means height drives the scale, so it needs the wider file.
140 AvatarSrc: avatarURL(),
141 MenuSrc: pourURL("006", images.CardWidths[len(images.CardWidths)-1]),
142 MenuSrcset: pourSrcset("006", images.CardWidths[len(images.CardWidths)-1], images.LightboxWidth),
143 OGImage: baseURL + "/static/og/card.png",
144 JSONLD: pageGraph(fullTitle, description, canonical, crumbs),
145 }
146}
147
148func (s *site) home(w http.ResponseWriter, r *http.Request) {
149 // "/" matches everything, so an unmatched path lands here and would answer
150 // 200. An explicit check also covers rendering the 404 page.
151 if r.URL.Path != "/" {
152 s.notFound(w, r)
153 return
154 }
155
156 data := s.page("index", "Senior Solutions Architect at Craftmaster Furniture", "")
157 // No card at all when the blog cannot be reached, rather than a stale one.
158 if post, ok := s.latest.Get(); ok {
159 data.Latest = &post
160 }
161 // The only page that holds the curtain, since it waits on the hero image.
162 data.LoaderWaits = true
163 // Full-bleed 100vw, so it is the one image with a 2400w candidate.
164 data.HeroSrc = pourURL(images.Hero, images.LightboxWidth)
165 data.HeroSrcset = pourSrcset(images.Hero,
166 images.CardWidths[len(images.CardWidths)-1], images.LightboxWidth, images.HeroWidth)
167 s.renderer.Render(w, http.StatusOK, "index.html", data)
168}
169
170func (s *site) about(w http.ResponseWriter, r *http.Request) {
171 data := s.page("about", "About", "A brief professional history of myself.")
172
173 data.Words = make([]AboutWord, 0, len(aboutWords))
174 for i, word := range aboutWords {
175 data.Words = append(data.Words, AboutWord{
176 Text: word,
177 Style: template.CSS(fmt.Sprintf("top: %dvh", i*11)),
178 })
179 }
180
181 s.renderer.Render(w, http.StatusOK, "about.html", data)
182}
183
184func (s *site) code(w http.ResponseWriter, r *http.Request) {
185 data := s.page("code", "Code", "Some of my most recent coding projects.")
186
187 // Three lists, because the template renders three sections with different
188 // chrome. The delay counts across all of them, so the entrance reads as one
189 // sequence rather than restarting at each heading.
190 delay := 0
191 nextDelay := func() template.CSS {
192 style := template.CSS(fmt.Sprintf("animation-delay: %dms", delay*100))
193 delay++
194 return style
195 }
196
197 data.Live = make([]SiteView, 0, len(sites))
198 for _, live := range sites {
199 data.Live = append(data.Live, SiteView{
200 Site: live,
201 Source: "https://github.com/" + githubUser + "/" + monorepo + "/tree/main/" + live.SourcePath(),
202 Commit: s.commits.JSON(live.SourcePath()),
203 Delay: nextDelay(),
204 })
205 }
206
207 for _, project := range projects {
208 view := ProjectView{
209 Project: project,
210 URL: "https://github.com/" + githubUser + "/" + project.Slug,
211 Delay: nextDelay(),
212 }
213 // On a read-only repo it is always the commit that archived it.
214 if !project.Archived {
215 view.Commit = s.commits.JSON(project.Slug)
216 }
217
218 if project.Archived {
219 data.Archived = append(data.Archived, view)
220 } else {
221 data.Active = append(data.Active, view)
222 }
223 }
224
225 s.renderer.Render(w, http.StatusOK, "code.html", data)
226}
227
228func (s *site) art(w http.ResponseWriter, r *http.Request) {
229 data := s.page("art", "Art", "Some of my art... what even is art...")
230
231 data.Pours = make([]PourView, 0, len(pours))
232 for _, pour := range pours {
233 data.Pours = append(data.Pours, PourView{
234 Pour: pour,
235 CardSrc: pourURL(pour.Number, images.CardWidths[0]),
236 CardSrcset: pourSrcset(pour.Number, images.CardWidths...),
237 LightboxSrc: pourURL(pour.Number, images.LightboxWidth),
238 })
239 }
240 data.Generative = generative
241
242 s.renderer.Render(w, http.StatusOK, "art.html", data)
243}
244
245func (s *site) contact(w http.ResponseWriter, r *http.Request) {
246 data := s.page("contact", "Contact", "How to get in contact with me.")
247 // The contact page breaks out of the centre column into the full grid.
248 data.GridArea = template.CSS("1 / 1 / 4 / 7")
249 data.Methods = contactMethods
250 s.renderer.Render(w, http.StatusOK, "contact.html", data)
251}
252
253func (s *site) notFound(w http.ResponseWriter, r *http.Request) {
254 data := s.page("notfound", "Not Found", "That page does not exist.")
255 s.renderer.Render(w, http.StatusNotFound, "notfound.html", data)
256}
257
258// robots keeps a staging hostname out of search results, since a noindex meta
259// tag only works if the crawler fetches the page.
260func (s *site) robots(w http.ResponseWriter, r *http.Request) {
261 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
262 if Staging {
263 fmt.Fprintf(w, "User-agent: *\nDisallow: /\n")
264 return
265 }
266 fmt.Fprintf(w, "User-agent: *\nDisallow:\n\nSitemap: %s/sitemap.xml\n", baseURL)
267}
268
269func (s *site) sitemap(w http.ResponseWriter, r *http.Request) {
270 var b strings.Builder
271 b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
272 b.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` + "\n")
273 for _, page := range navPages {
274 loc := baseURL
275 if page.Href != "/" {
276 loc += page.Href
277 }
278 fmt.Fprintf(&b, " <url><loc>%s</loc></url>\n", loc)
279 }
280 b.WriteString("</urlset>\n")
281
282 w.Header().Set("Content-Type", "application/xml; charset=utf-8")
283 _, _ = w.Write([]byte(b.String()))
284}
285
286func (s *site) manifest(w http.ResponseWriter, r *http.Request) {
287 w.Header().Set("Content-Type", "application/manifest+json")
288 fmt.Fprintf(w, `{
289 "name": %q,
290 "short_name": %q,
291 "background_color": %q,
292 "display": "standalone",
293 "scope": "/",
294 "start_url": "/",
295 "icons": [
296 {
297 "src": "/static/images/favicon.png",
298 "type": "image/png",
299 "sizes": "512x512"
300 }
301 ],
302 "theme_color": %q
303}
304`, siteTitle, siteTitle, themeColor, themeColor)
305}