Skip to content

Architecture

Schreibzeug is a single-page app that edits Office files in the browser with the Euro-Office engine, and one Bun server that stores the files and relays collaboration. The same app runs in the browser and in the Tauri desktop app.

apps/web (React SPA) ──HTTP /api──▶ apps/server (Bun + Hono) ──▶ SQLite (tree, versions, accounts)
   │                                         │                └─▶ data/blobs (file bytes)
   └─ iframe: engine (sdkjs + x2t)           ├─ WebSocket relay (collaboration)
MCP client (Claude Code) ──HTTP /mcp────────▶└─ MCP tools (packages/mcp) → the same /api

Workspaces

Workspace What it does
packages/editor-core The only code that calls sdkjs: loads the engine, opens and saves files, turns engine events into a store, for the text, spreadsheet, presentation and PDF editors. Framework-free. See the engine and PDFs.
packages/text-editor The editor for Markdown and plain text files on CodeMirror 6, without a UI framework: reads and writes a file's own encoding and line endings, the live preview, the toolbar's Markdown commands, the split view's preview. See Markdown and text files.
packages/mcp The MCP server's tools for Claude Code and other clients: find, read, manage and create files, each through the files API with the caller's token. See the MCP server.
packages/files-api The contract between server and clients: Zod schemas for names, nodes, versions and errors.
packages/einvoice E-invoices: finds and reads the invoice XML a ZUGFeRD or Factur-X PDF carries (CII and UBL, taken from Haben), and writes one: reads an invoice from a document's text, writes Factur-X XML (EN 16931) and makes the PDF/A a ZUGFeRD PDF/A-3. See E-invoices.
packages/pdf-ocr Searchable scans: finds the pages of a PDF without text and lays recognised text over them, invisible. See PDFs.
packages/pdf-sign Digital signatures in PDFs: signs with a PKCS#12 certificate (PKCS#7, detached) and checks the signatures a PDF holds. See PDFs.
packages/ooxml Builds minimal Office Open XML packages (.docx, .xlsx, .pptx): blank files for the server, test files for the tests; reads the text of a .docx (for e-invoices and the MCP server), the sheets of an .xlsx and the slides of a .pptx.
apps/server The files API on Bun with Hono, Drizzle and SQLite (bun:sqlite). See files; accounts with Better Auth in src/auth/, see accounts. The collaboration relay in src/collab/, see collaboration.
apps/local The desktop app's local server: the same files API over a folder on the computer, for one person, without accounts. See the desktop app.
apps/desktop The desktop app (Tauri 3, alpha) for Windows, macOS and Linux: the files on this computer through apps/local, or a window onto a Schreibzeug server. See the desktop app.
apps/web The React interface: file pages (src/files/), the editor page, routes in src/router.tsx, the typed API client in src/api/, the talk with Kontext when shown in its frame (src/embed/, see Schreibzeug in Kontext), the demo without a server (src/demo/, see development).

Why a SPA and a Hono API, not TanStack Start

The plan started with TanStack Start, whose server functions call server code like local functions. They only exist while the Start server runs. The desktop app ships the interface as static files, without that server, and would still need a plain HTTP API to reach a Schreibzeug server. So the interface is a plain SPA (Vite, TanStack Router and Query), and the server is a small API that browser and desktop call the same way.

Hono is a thin layer over Bun.serve: routing, middleware, Zod validation, and a typed client (hc<AppType>) that takes its types from the server's routes. That client gives the web app the same end-to-end types server functions would. The collaboration relay runs in the same process on Hono's WebSocket upgrade, so access checks for files and editing sessions live in one place.

The web app

Routes are defined in code (src/router.tsx): /login signs in or creates an account; every other page needs a session (accounts). / and /folders/:id list a folder, /recent, /shared (what others shared, sharing) and /trash the other views, /settings the language of the interface, the storage the files take and the access tokens for MCP clients (files, MCP server; in local mode only the language), /invite/:token a link someone shared (readable before signing in), /edit/:id opens a file in its editor with the whole window (src/editor/: the word view for .docx, the sheet view for .xlsx, the slide view for .pptx, the PDF view for .pdf, the text view for .md and .txt, sharing the header with the editable name, the menu bar, status, versions, download and save). Reads go through TanStack Query with the keys in src/api/files.ts; every write refreshes all keys under files. The editor page joins the file's editing session first (src/editor/use-session-file.ts) and opens the version the session started from, so refreshing the lists after a save does not reopen the document. A text file has no session: its page loads the newest version and saves the whole file over it (src/editor/text-page.tsx, Markdown and text files). Every reply is checked against the schemas in packages/files-api.

In development bun run dev prepares the engine if needed, starts the server and serves the app on port 5173, which forwards /api, WebSockets included, and /mcp to the server (SCHREIBZEUG_API_URL, default http://localhost:3000). The E2E run starts its own server on an empty database (e2e/server.ts, port 3100) and the app on port 5174; beside them the desktop app's local server with an app of its own (e2e/local-server.ts), and a stand-in for Kontext with a server signed in through it (e2e/fake-kontext.ts, e2e/kontext-server.ts; e2e/playwright.config.ts). Without Playwright's own browser download, an installed Chromium can be used: PLAYWRIGHT_CHROMIUM_PATH=/path/to/chrome bun run test:e2e.

Interface texts

The interface speaks German and English. Its texts are in apps/web/messages/de.json and apps/web/messages/en.json (the inlang message format); Paraglide JS compiles them into one typed function per message in apps/web/src/paraglide (generated, not committed): the code calls m.share_dialog_title() or m.folder_page_deleted({ name }), and a missing message or placeholder is a type error. The Vite plugin compiles on dev and build; bun run i18n does it alone, and typecheck and test run it first. apps/web/project.inlang holds the project: German is the base language, the format plugin comes from node_modules (not from a CDN, so the build works offline), and paraglide.config.js sets how the language is chosen: the one picked under Settings → Language (localStorage schreibzeug.locale), else the browser's, else German. Changing it loads the page anew.

A key starts with the file that uses it (word_menus_undo in word-menus.ts); a few texts many files share are common_*. Shortcuts are written with English key names and shown in the interface's language (apps/web/src/lib/keys.ts: keys("Ctrl+Z") is "Strg+Z" in German). packages/editor-core stays without Paraglide: the texts the engine draws itself and the screen reader's words come from the app as data. Words read from documents stay German whatever the interface says (an invoice's "Rechnungsnummer"), as does what the agent tools say. Not translated yet: what the server answers (its error messages), the checks the server shares with the app (packages/files-api names, packages/einvoice and packages/pdf-sign problems) and the names of the built-in templates (packages/ooxml), which are German documents.

apps/web/i18n.test.ts keeps it that way in bun run test, and with it in CI: both languages carry the same messages with the same placeholders, none is empty, every message is used, and no text is written in the code. That last check (apps/web/i18n-check.ts) finds JSX text, the attributes and fields that hold labels (aria-label, title, placeholder, label, …) and string literals with German letters or words; a literal that is data, not text, carries an // i18n-ignore comment saying why. bun apps/web/i18n-check.ts [file …] lists what it finds. Unit tests read the German texts (apps/web/vitest.setup.ts), the E2E specs run in a German browser (locale: "de-DE" in e2e/playwright.config.ts).

Screenshots

docs/screenshots holds the pictures for the README and the docs, light and dark. They are taken by e2e/screenshots.shot.ts (bun run screenshots), in CI by hand with Actions → Update screenshots, which commits the pictures that changed. The run seeds the server with the same folders, the letter from test-files/angebot.docx and the form from test-files/formular.pdf, pins their dates in the database and the browser's clock, and ships Geist as the interface font, so an unchanged page gives an unchanged picture.

Format fidelity

test-files/fidelity holds real .docx, .xlsx and .pptx files from LibreOffice's test suite (MPL-2.0), one per area: in documents tables, lists, footnotes, headers, images, text boxes, columns, a table of contents, comments, charts and shapes; in spreadsheets borders, formats, theme colours, filters, charts, colour scales and data bars; in presentations shapes, bullets, themes, table styles, groups and SmartArt (test-files/README.md). e2e/formats.fidelity.ts opens each one and compares the first screen with its reference picture in test-files/fidelity/screenshots; then it saves the file unchanged, opens the saved version and compares again, so a round trip through the engine and x2t must not change how a document looks. The references are taken in CI with Actions → Update screenshots (bun run fidelity:update); CI checks them on every push as part of bun run test:e2e, which it runs in three shards at once. An engine update passes only when they stay the same.