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.9 KB · 117 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// 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}