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.8 KB · 115 lines · Go Raw History
  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}