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

2.9 KB · 101 lines · Go Raw History
  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}