repos
/ orchard main

orchard

mirror

Every 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

8.2 KB · 306 lines · Go Raw History
  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}