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 "fmt"
5 "log/slog"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "sync"
12)
13
14// Social cards, compiled at build time like the PDFs. PNG rather than SVG:
15// Facebook, X, LinkedIn, Slack, iMessage and Discord all refuse image/svg+xml
16// for og:image.
17
18const (
19 ogWidth = 1200
20 ogHeight = 630
21
22 // Named so it cannot collide with a post slug.
23 ogSiteCard = "blog"
24)
25
26// ogTypstSource renders one card. Text reaches Typst as string literals, so a
27// title containing #, $, @ or * is drawn rather than parsed.
28func ogTypstSource(title string, tags []string) string {
29 var b strings.Builder
30
31 fmt.Fprintf(&b, "#set page(width: %dpt, height: %dpt, margin: 0pt, fill: rgb(\"#0d1117\"))\n", ogWidth, ogHeight)
32 // Geist comes in through --font-path. Typst embeds only DejaVu Sans Mono
33 // and its own serif, and a missing face falls back rather than erroring.
34 b.WriteString("#set text(font: (\"Geist\", \"DejaVu Sans\"), fill: rgb(\"#f0f6fc\"))\n")
35
36 b.WriteString("#place(dx: 80pt, dy: 80pt, rect(width: 5pt, height: 160pt, " +
37 "fill: gradient.linear(rgb(\"#0e3ff4\"), rgb(\"#842bff\"), angle: 90deg)))\n")
38
39 // Fixed baselines, so the card does not reflow on a different wrap.
40 for i, line := range wrapTitle(title, 35, 3) {
41 fmt.Fprintf(&b, "#place(dx: 110pt, dy: %dpt, text(size: 54pt, weight: \"bold\")[#%s])\n",
42 110+i*64, typstString(line))
43 }
44
45 fmt.Fprintf(&b, "#place(dx: 80pt, dy: 490pt, line(length: %dpt, stroke: 1pt + rgb(\"#30363d\")))\n", ogWidth-160)
46 fmt.Fprintf(&b, "#place(dx: 80pt, dy: 520pt, text(size: 28pt, fill: rgb(\"#c9d1d9\"))[#%s])\n",
47 typstString(authorName))
48
49 shown := tags
50 if len(shown) > 4 {
51 shown = shown[:4]
52 }
53 for i, tag := range shown {
54 x := (ogWidth - 80) - (len(shown)-i)*140
55 fmt.Fprintf(&b, "#place(dx: %dpt, dy: 520pt, block(width: 128pt, height: 38pt, radius: 19pt, "+
56 "fill: rgb(\"#21262d\"), inset: (y: 8pt), align(center, "+
57 "text(size: 18pt, fill: rgb(\"#c9d1d9\"))[#%s])))\n", x, typstString(tag))
58 }
59
60 return b.String()
61}
62
63// typstString quotes a Go string as a Typst string literal. Only the backslash
64// and the quote can end one early, markup characters included.
65func typstString(s string) string {
66 r := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
67 return `"` + r.Replace(s) + `"`
68}
69
70// GenerateOGCards compiles one PNG per post into outDir, plus the site card.
71func GenerateOGCards(lib *Library, root, fontPath, outDir string) error {
72 if _, err := exec.LookPath("typst"); err != nil {
73 return fmt.Errorf("typst not on PATH: %w", err)
74 }
75 if err := os.MkdirAll(outDir, 0o755); err != nil {
76 return err
77 }
78
79 type job struct {
80 name string
81 source string
82 }
83 jobs := []job{{name: ogSiteCard, source: ogTypstSource(siteName, nil)}}
84 for _, post := range lib.All() {
85 jobs = append(jobs, job{name: post.Slug, source: ogTypstSource(post.Title, post.Tags)})
86 }
87
88 workers := runtime.NumCPU()
89 if workers > len(jobs) {
90 workers = len(jobs)
91 }
92
93 var (
94 wg sync.WaitGroup
95 mu sync.Mutex
96 errs []string
97 queue = make(chan job)
98 )
99
100 for i := 0; i < workers; i++ {
101 wg.Add(1)
102 go func() {
103 defer wg.Done()
104 for j := range queue {
105 out := filepath.Join(outDir, j.name+".png")
106 if err := compilePNG(j.source, root, fontPath, out); err != nil {
107 mu.Lock()
108 errs = append(errs, fmt.Sprintf("%s: %v", j.name, err))
109 mu.Unlock()
110 continue
111 }
112 slog.Info(fmt.Sprintf("og %s", j.name))
113 }
114 }()
115 }
116 for _, j := range jobs {
117 queue <- j
118 }
119 close(queue)
120 wg.Wait()
121
122 if len(errs) > 0 {
123 return fmt.Errorf("compile failed for %d card(s):\n %s", len(errs), strings.Join(errs, "\n "))
124 }
125 return nil
126}
127
128// compilePNG is compilePDF with a raster target. At 72 ppi a Typst point is one
129// pixel, so the page size above is the pixel size og:image:width promises.
130func compilePNG(source, root, fontPath, out string) error {
131 args := []string{"compile", "--format", "png", "--ppi", "72", "--root", root}
132 if fontPath != "" {
133 args = append(args, "--font-path", fontPath)
134 }
135 args = append(args, "-", out)
136 cmd := exec.Command("typst", args...)
137 cmd.Stdin = strings.NewReader(source)
138
139 var stderr strings.Builder
140 cmd.Stderr = &stderr
141
142 if err := cmd.Run(); err != nil {
143 return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
144 }
145 return nil
146}