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.5 KB · 345 lines · Go Raw History
  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}