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

4.2 KB · 147 lines · Go Raw History
  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}