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// PDFs are compiled during `docker build` and the handler serves a file, so no
15// subprocess sits on the request path. Every post is rendered, scheduled ones
16// included, and the handler is what enforces the publish date.
17
18// GeneratePDFs compiles one PDF per post into outDir. root is the directory
19// Typst resolves absolute paths against, so /templates/blog_post.typ and
20// /content/images/* both land.
21func GeneratePDFs(lib *Library, root, fontPath, outDir string) error {
22 if _, err := exec.LookPath("typst"); err != nil {
23 return fmt.Errorf("typst not on PATH: %w", err)
24 }
25 if err := os.MkdirAll(outDir, 0o755); err != nil {
26 return err
27 }
28
29 posts := lib.All()
30 // Typst is single threaded per compile, and these are independent.
31 workers := runtime.NumCPU()
32 if workers > len(posts) {
33 workers = len(posts)
34 }
35
36 var (
37 wg sync.WaitGroup
38 mu sync.Mutex
39 errs []string
40 queue = make(chan *Post)
41 )
42
43 for i := 0; i < workers; i++ {
44 wg.Add(1)
45 go func() {
46 defer wg.Done()
47 for post := range queue {
48 out := filepath.Join(outDir, post.Slug+".pdf")
49 if err := compilePDF(typstSource(post), root, fontPath, out); err != nil {
50 mu.Lock()
51 errs = append(errs, fmt.Sprintf("%s: %v", post.Slug, err))
52 mu.Unlock()
53 continue
54 }
55 slog.Info(fmt.Sprintf("pdf %s", post.Slug))
56 }
57 }()
58 }
59 for _, post := range posts {
60 queue <- post
61 }
62 close(queue)
63 wg.Wait()
64
65 if len(errs) > 0 {
66 return fmt.Errorf("compile failed for %d post(s):\n %s", len(errs), strings.Join(errs, "\n "))
67 }
68 return nil
69}
70
71// compilePDF pipes Typst markup to the compiler on stdin, so there is nothing
72// to clean up on failure and nothing for parallel workers to collide over.
73func compilePDF(source, root, fontPath, out string) error {
74 args := []string{"compile", "--root", root}
75 if fontPath != "" {
76 args = append(args, "--font-path", fontPath)
77 }
78 args = append(args, "-", out)
79 cmd := exec.Command("typst", args...)
80 cmd.Stdin = strings.NewReader(source)
81
82 // Typst reports compile errors on stderr and exits non-zero, so the exit
83 // code alone is not the diagnosis.
84 var stderr strings.Builder
85 cmd.Stderr = &stderr
86
87 if err := cmd.Run(); err != nil {
88 return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
89 }
90 return nil
91}