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.3 KB · 92 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// 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}