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