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 "encoding/json"
5 "html/template"
6 "strconv"
7 "strings"
8)
9
10// jsonLD marshals a graph into the bytes that go inside the script tag.
11// template.JS skips html/template's escaping, which is safe because
12// encoding/json escapes <, > and & and so cannot close the element.
13func jsonLD(nodes ...map[string]any) template.JS {
14 graph := map[string]any{
15 "@context": "https://schema.org",
16 "@graph": nodes,
17 }
18 // Indented for view-source; gzip removes the difference.
19 buf, err := json.MarshalIndent(graph, "", " ")
20 if err != nil {
21 return ""
22 }
23 return template.JS(buf)
24}
25
26// personNode is the author, stated once and referenced by @id everywhere else.
27func personNode() map[string]any {
28 return map[string]any{
29 "@type": "Person",
30 "@id": authorID,
31 "name": authorName,
32 "url": "https://isaacbythewood.com/",
33 "jobTitle": "Senior Solutions Architect",
34 "sameAs": []string{
35 "https://github.com/" + githubUser,
36 "https://isaacbythewood.com/",
37 },
38 }
39}
40
41const (
42 authorID = "https://isaacbythewood.com/#person"
43 websiteID = baseURL + "/#website"
44)
45
46// siteGraph is what every page that is not a post carries.
47func siteGraph() template.JS {
48 return jsonLD(personNode(), map[string]any{
49 "@type": "Blog",
50 "@id": websiteID,
51 "url": baseURL + "/",
52 "name": siteName,
53 "description": "Writing about webdev, infrastructure, security, and tooling.",
54 "inLanguage": "en",
55 "author": map[string]any{"@id": authorID},
56 "publisher": map[string]any{"@id": authorID},
57 })
58}
59
60// postGraph describes one article, plus the breadcrumb trail that leads to it.
61func postGraph(p *Post) template.JS {
62 article := map[string]any{
63 "@type": "BlogPosting",
64 "@id": baseURL + p.URL() + "#article",
65 "headline": p.Title,
66 "description": p.Description,
67 "url": baseURL + p.URL(),
68 "datePublished": feedTime(p.PublishDate),
69 "dateModified": feedTime(p.Date),
70 "author": map[string]any{"@id": authorID},
71 "publisher": map[string]any{"@id": authorID},
72 "isPartOf": map[string]any{"@id": websiteID},
73 "mainEntityOfPage": map[string]any{"@type": "WebPage", "@id": baseURL + p.URL()},
74 "inLanguage": "en",
75 "image": map[string]any{
76 "@type": "ImageObject",
77 "url": p.OGImage(),
78 "width": ogWidth,
79 "height": ogHeight,
80 },
81 }
82 if len(p.Tags) > 0 {
83 article["keywords"] = strings.Join(p.Tags, ", ")
84 }
85 if p.ReadTime > 0 {
86 // Schema.org wants an ISO 8601 duration.
87 article["timeRequired"] = "PT" + strconv.Itoa(p.ReadTime) + "M"
88 }
89
90 crumbs := map[string]any{
91 "@type": "BreadcrumbList",
92 "itemListElement": []map[string]any{
93 {"@type": "ListItem", "position": 1, "name": "Home", "item": baseURL + "/"},
94 {"@type": "ListItem", "position": 2, "name": "Blog", "item": baseURL + "/blog/"},
95 {"@type": "ListItem", "position": 3, "name": p.Title, "item": baseURL + p.URL()},
96 },
97 }
98
99 return jsonLD(personNode(), article, crumbs)
100}