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 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "strings"
11 "sync"
12 "time"
13)
14
15// typstTimeout bounds one compile, since a subprocess can hang a request in a
16// way that outlives it.
17const typstTimeout = 30 * time.Second
18
19// Typst runs the CLI, or reports once that it cannot.
20type Typst struct {
21 once sync.Once
22 bin string
23 err error
24}
25
26func NewTypst() *Typst { return &Typst{} }
27
28// ErrTypstMissing means no typst binary is on PATH, the normal state of a local
29// checkout. The PDF route answers 503; every other route still works.
30var ErrTypstMissing = errors.New("typst binary not found on PATH")
31
32func (t *Typst) resolve() (string, error) {
33 t.once.Do(func() {
34 bin, err := exec.LookPath("typst")
35 if err != nil {
36 t.err = ErrTypstMissing
37 return
38 }
39 t.bin = bin
40 })
41 return t.bin, t.err
42}
43
44// Render compiles Typst markup to PDF bytes. root bounds what the compiler may
45// read, which matters because the source is assembled from a template carrying
46// property names and page URLs.
47func (t *Typst) Render(ctx context.Context, root, source string) ([]byte, error) {
48 bin, err := t.resolve()
49 if err != nil {
50 return nil, err
51 }
52
53 ctx, cancel := context.WithTimeout(ctx, typstTimeout)
54 defer cancel()
55
56 var stdout, stderr bytes.Buffer
57 cmd := exec.CommandContext(ctx, bin, "compile", "--root", root, "-", "-")
58 cmd.Stdin = strings.NewReader(source)
59 cmd.Stdout = &stdout
60 cmd.Stderr = &stderr
61 cmd.Dir = root
62 // Typst reads fonts through fontconfig, which warns on every compile without
63 // somewhere writable for its cache.
64 cmd.Env = append(os.Environ(), "XDG_CACHE_HOME=/tmp")
65
66 if err := cmd.Run(); err != nil {
67 if ctx.Err() == context.DeadlineExceeded {
68 return nil, fmt.Errorf("typst compile timed out after %s", typstTimeout)
69 }
70 return nil, fmt.Errorf("typst compile: %w: %s", err, strings.TrimSpace(stderr.String()))
71 }
72 if stdout.Len() == 0 {
73 return nil, fmt.Errorf("typst produced no output: %s", strings.TrimSpace(stderr.String()))
74 }
75 return stdout.Bytes(), nil
76}
77
78// typstMD escapes a string for Typst's markup mode. "/" is in the set because
79// "//" starts a Typst line comment and page URLs are user data.
80func typstMD(s string) string {
81 var b strings.Builder
82 b.Grow(len(s))
83 for _, r := range s {
84 switch r {
85 case '\\', '[', ']', '*', '_', '`', '#', '$', '<', '@', '~', '/':
86 b.WriteByte('\\')
87 }
88 b.WriteRune(r)
89 }
90 return b.String()
91}
92
93// typstStr escapes a string for a Typst string literal.
94func typstStr(s string) string {
95 var b strings.Builder
96 b.Grow(len(s))
97 for _, r := range s {
98 switch r {
99 case '\\':
100 b.WriteString(`\\`)
101 case '"':
102 b.WriteString(`\"`)
103 case '\n':
104 b.WriteString(`\n`)
105 case '\r':
106 b.WriteString(`\r`)
107 case '\t':
108 b.WriteString(`\t`)
109 default:
110 b.WriteRune(r)
111 }
112 }
113 return b.String()
114}