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 "html/template"
5 "io/fs"
6 "path"
7 "sort"
8 "strings"
9 "sync"
10 "time"
11)
12
13// Post is one Markdown file under content/posts, parsed once at startup. A new
14// post arrives with a deploy, and a deploy restarts the process.
15type Post struct {
16 Filename string
17 Title string
18 Slug string
19 Date string
20 PublishDate string
21 Tags []string
22 Description string
23 CoverImage string
24 ReadTime int
25
26 // template.HTML because the Markdown is the author's own, which is where
27 // this site draws its trust boundary.
28 BodyHTML template.HTML
29 BodyTypst string
30}
31
32// Methods rather than template helpers, so the compiler checks them.
33func (p *Post) URL() string { return "/posts/" + p.Slug + "/" }
34func (p *Post) PDFURL() string { return "/posts/" + p.Slug + "/pdf/" }
35func (p *Post) MDURL() string { return "/posts/" + p.Slug + "/md/" }
36func (p *Post) OGImage() string { return baseURL + "/og/" + p.Slug + ".png" }
37func (p *Post) CoverURL() string { return "/content/images/" + p.CoverImage }
38
39// TagLinks pairs each tag with its filter URL.
40func (p *Post) TagLinks() []TagEntry {
41 out := make([]TagEntry, 0, len(p.Tags))
42 for _, t := range p.Tags {
43 out = append(out, TagEntry{Name: t, Display: titleCase(t), URL: tagURL(t)})
44 }
45 return out
46}
47
48// TagEntry is one tag wherever a tag is shown. Count is zero where the tag is
49// not being used as a facet.
50type TagEntry struct {
51 Name string
52 Display string
53 URL string
54 Count int
55}
56
57func tagURL(tag string) string { return "/blog/tag/" + urlPathEscape(tag) + "/" }
58func yearURL(year string) string { return "/blog/year/" + urlPathEscape(year) + "/" }
59
60// Library holds every post. The published set is cached per day, not computed
61// once, so a future publish_date becomes visible without a restart.
62type Library struct {
63 all []*Post
64 bySlug map[string]*Post
65
66 mu sync.Mutex
67 cachedDay string
68 published []*Post
69 tags []TagEntry
70 years []string
71}
72
73// LoadLibrary reads every .md file under posts/. It takes an fs.FS rather than a
74// path because the content ships inside the binary.
75func LoadLibrary(content fs.FS) (*Library, error) {
76 entries, err := fs.ReadDir(content, "posts")
77 if err != nil {
78 return nil, err
79 }
80
81 lib := &Library{bySlug: make(map[string]*Post)}
82 for _, entry := range entries {
83 if entry.IsDir() || path.Ext(entry.Name()) != ".md" {
84 continue
85 }
86 raw, err := fs.ReadFile(content, path.Join("posts", entry.Name()))
87 if err != nil {
88 return nil, err
89 }
90 post := parsePost(entry.Name(), string(raw))
91 lib.all = append(lib.all, post)
92 lib.bySlug[post.Slug] = post
93 }
94
95 // Newest first, tie-broken on filename so two posts sharing a date do not
96 // swap places between builds.
97 sort.SliceStable(lib.all, func(i, j int) bool {
98 if lib.all[i].Date != lib.all[j].Date {
99 return lib.all[i].Date > lib.all[j].Date
100 }
101 return lib.all[i].Filename < lib.all[j].Filename
102 })
103
104 return lib, nil
105}
106
107// parsePost splits frontmatter from body and renders both output formats.
108func parsePost(filename, text string) *Post {
109 meta, body := parseFrontmatter(text)
110
111 post := &Post{
112 Filename: filename,
113 Title: meta["title"],
114 Slug: meta["slug"],
115 Date: meta["date"],
116 PublishDate: meta["publish_date"],
117 Description: meta["description"],
118 CoverImage: meta["cover_image"],
119 }
120 if post.Slug == "" {
121 post.Slug = strings.TrimSuffix(filename, ".md")
122 }
123 if post.PublishDate == "" {
124 post.PublishDate = post.Date
125 }
126 for _, tag := range strings.Split(meta["tags"], ",") {
127 if tag = strings.TrimSpace(tag); tag != "" {
128 post.Tags = append(post.Tags, tag)
129 }
130 }
131
132 post.BodyHTML = renderMarkdown(body)
133 post.BodyTypst = typstFromMarkdown(body)
134
135 // 200 words a minute, rounded up, never zero.
136 words := len(strings.Fields(body))
137 post.ReadTime = (words + 199) / 200
138 if post.ReadTime < 1 {
139 post.ReadTime = 1
140 }
141
142 return post
143}
144
145// parseFrontmatter reads the leading --- block as flat key: value pairs, which
146// is not YAML. The closing delimiter has to start a line, or a horizontal rule
147// inside a description ends the block early.
148func parseFrontmatter(text string) (map[string]string, string) {
149 meta := map[string]string{}
150 if !strings.HasPrefix(text, "---") {
151 return meta, text
152 }
153 rest := text[3:]
154 end := strings.Index(rest, "\n---")
155 if end < 0 {
156 return meta, text
157 }
158 block := rest[:end+1]
159 body := strings.TrimLeft(rest[end+4:], "\r\n \t")
160
161 for _, line := range strings.Split(strings.TrimSpace(block), "\n") {
162 key, value, ok := strings.Cut(line, ": ")
163 if !ok {
164 continue
165 }
166 meta[strings.TrimSpace(key)] = strings.TrimSpace(value)
167 }
168 return meta, body
169}
170
171// today is local, which is the zone publish_date is written in.
172func today() string { return time.Now().Format("2006-01-02") }
173
174// Published returns the visible posts, newest first, along with the tag and
175// year facets computed from that same set.
176func (l *Library) Published() ([]*Post, []TagEntry, []string) {
177 l.mu.Lock()
178 defer l.mu.Unlock()
179
180 day := today()
181 if l.cachedDay == day {
182 return l.published, l.tags, l.years
183 }
184
185 published := make([]*Post, 0, len(l.all))
186 for _, p := range l.all {
187 if p.PublishDate <= day {
188 published = append(published, p)
189 }
190 }
191
192 l.cachedDay = day
193 l.published = published
194 l.tags = collectTags(published)
195 l.years = collectYears(published)
196 return l.published, l.tags, l.years
197}
198
199// Lookup finds a post by slug, reporting false for an unpublished one. A caller
200// should 404 rather than 403, since a 403 confirms the slug.
201func (l *Library) Lookup(slug string) (*Post, bool) {
202 post, ok := l.bySlug[slug]
203 if !ok || post.PublishDate > today() {
204 return nil, false
205 }
206 return post, true
207}
208
209// All returns every post regardless of publish date, so the PDF build has a
210// scheduled post's PDF ready before it is visible.
211func (l *Library) All() []*Post { return l.all }
212
213func collectTags(posts []*Post) []TagEntry {
214 counts := map[string]int{}
215 for _, p := range posts {
216 for _, t := range p.Tags {
217 counts[t]++
218 }
219 }
220 out := make([]TagEntry, 0, len(counts))
221 for name, count := range counts {
222 out = append(out, TagEntry{
223 Name: name,
224 Display: titleCase(name),
225 URL: tagURL(name),
226 Count: count,
227 })
228 }
229 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
230 return out
231}
232
233func collectYears(posts []*Post) []string {
234 seen := map[string]bool{}
235 var out []string
236 for _, p := range posts {
237 if len(p.Date) < 4 {
238 continue
239 }
240 if year := p.Date[:4]; !seen[year] {
241 seen[year] = true
242 out = append(out, year)
243 }
244 }
245 sort.Sort(sort.Reverse(sort.StringSlice(out)))
246 return out
247}
248
249func byTag(posts []*Post, tag string) (matched, others []*Post) {
250 for _, p := range posts {
251 if containsFold(p.Tags, tag) {
252 matched = append(matched, p)
253 } else {
254 others = append(others, p)
255 }
256 }
257 return matched, others
258}
259
260func byYear(posts []*Post, year string) (matched, others []*Post) {
261 for _, p := range posts {
262 if strings.HasPrefix(p.Date, year) {
263 matched = append(matched, p)
264 } else {
265 others = append(others, p)
266 }
267 }
268 return matched, others
269}
270
271func containsFold(haystack []string, needle string) bool {
272 for _, s := range haystack {
273 if s == needle {
274 return true
275 }
276 }
277 return false
278}
279
280// related picks posts sharing the most tags, topped up with recent ones.
281func related(post *Post, posts []*Post, count int) []*Post {
282 tags := map[string]bool{}
283 for _, t := range post.Tags {
284 tags[t] = true
285 }
286
287 type scored struct {
288 post *Post
289 overlap int
290 }
291 var candidates []scored
292 for _, p := range posts {
293 if p.Slug == post.Slug {
294 continue
295 }
296 overlap := 0
297 for _, t := range p.Tags {
298 if tags[t] {
299 overlap++
300 }
301 }
302 if overlap > 0 {
303 candidates = append(candidates, scored{p, overlap})
304 }
305 }
306 // Stable, so equal-overlap posts keep their newest-first order.
307 sort.SliceStable(candidates, func(i, j int) bool {
308 return candidates[i].overlap > candidates[j].overlap
309 })
310
311 out := make([]*Post, 0, count)
312 taken := map[string]bool{post.Slug: true}
313 for _, c := range candidates {
314 if len(out) == count {
315 break
316 }
317 out = append(out, c.post)
318 taken[c.post.Slug] = true
319 }
320 for _, p := range posts {
321 if len(out) == count {
322 break
323 }
324 if !taken[p.Slug] {
325 out = append(out, p)
326 taken[p.Slug] = true
327 }
328 }
329 return out
330}
331
332// titleCase capitalises each word of a tag name. strings.Title is deprecated and
333// golang.org/x/text is a dependency, for a dozen lowercase ASCII tags.
334func titleCase(s string) string {
335 out := []rune(s)
336 upper := true
337 for i, r := range out {
338 if upper && r >= 'a' && r <= 'z' {
339 out[i] = r - 32
340 }
341 upper = r == ' ' || r == '-'
342 }
343 return string(out)
344}