Skip to content

Repository files navigation

reactypst

Write documents in React. Print them with Typst.

HTML-to-PDF through a headless browser gives you web typography on paper: no hyphenation worth the name, widows and orphans, tables that split badly, and a Chromium download in your deploy. reactypst keeps the part of that workflow people like — components, props, loops, TypeScript — and replaces the printer. Your component tree becomes Typst markup; Typst typesets it the way a book is typeset.

import { Page } from "reactypst";

export default function Letter({ to }: { to: string }) {
  return (
    <Page size="a4" font="Libertinus Serif" numbering>
      <h1>Dear {to},</h1>
      <p style={{ textAlign: "justify" }}>Thank you for your order…</p>
    </Page>
  );
}
npx reactypst letter.tsx -o letter.pdf     # PDF through the typst CLI
npx reactypst letter.tsx --typ             # just the .typ, to inspect or commit

reactypst dev: the component, the Typst it becomes, the live PDF

invoice.tsx and the PDF it becomes

What maps to what

React Typst
h1…h6 = … ====== headings
p, div, section, header, footer, article paragraphs and block(...)
strong/b, em/i, u, s, small, sup, sub *…*, _…_, underline, strike, text(size), super, sub
ul, ol, li (nested) - and + lists
table, tr, th, td table(columns: n, …)
a, img, code, pre, blockquote, hr, br link, image, raw, quote, line, line break
style={{ … }} text(size, fill, weight, font, tracking) and block(fill, inset, stroke, radius, width, height), align(...)

Function components are called with their props, so composition, map, conditionals and shared layout components all work. Hooks that need a live renderer (state, effects) do not — a PDF has no second render.

Typst-only concepts come as components that preview harmlessly in a browser:

<Page size margin font fontSize numbering> #set page(...), #set text(...), justified paragraphs
<PageBreak /> #pagebreak()
<Columns count={2}> #columns(2)[…]
<Raw>{"#lorem(40)"}</Raw> Typst passed through untouched

Nothing is lost silently

Every element or CSS property without a clean Typst equivalent is reported once through onUnsupported (the CLI prints them). A box-shadow never just disappears from your invoice.

renderToTypst(<Invoice />, { onUnsupported: (what) => console.warn(what) });

Why Typst

  • Real line breaking and hyphenation, per language.
  • Tables, footnotes, page numbers and running headers that behave like print.
  • A single 30 MB binary instead of a headless browser; milliseconds per page.

As a service

service/ turns reactypst into a document server: save templates over GraphQL, queue renders, get signed S3 links to the PDFs. NestJS with Apollo, Prisma on PostgreSQL, RabbitMQ between the API and the render workers.

sequenceDiagram
  participant C as client
  participant A as API (Nest + Apollo)
  participant Q as RabbitMQ
  participant W as worker
  C->>A: render(slug, props)
  A->>Q: render id
  A-->>C: { id, state: QUEUED }
  Q->>W: render id
  W->>W: TSX → Typst → PDF
  W->>S3: put PDF
  C->>A: renderJob(id)
  A-->>C: { state: DONE, url }
Loading

Storybook

npm run storybook shows every component with the Typst markup it produces, and a rendered page when the preview server is running.

Development

npm install
npm test
npm run lint
npx tsx src/cli.ts examples/invoice.tsx --typ

Credits

  • Typst by Laurenz Mädje and Martin Haug does all the actual typesetting.
  • The reconciler-free rendering approach is borrowed in spirit from react-dom/server and from Vercel's satori.
  • Libertinus fonts in the examples by Khaled Hosny and the Libertinus contributors.

License

MIT © Ivan Novikov

About

Write documents in React, print them with Typst. Library, CLI and a GraphQL render service.

Topics

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages