Introduction
Werb is a file-driven homebrewing tool. Recipes in, brew sessions out — every artifact is plain JSON you can read, version, share, and round-trip with every other tool that speaks the format.
It runs three ways from the same codebase:
- A desktop app (Tauri) on macOS, Windows, and Linux.
- A Progressive Web App in any modern browser (including iPad Safari).
- A command-line tool,
werb, for batch conversion and validation.
Why “file-driven”?
Most brewing apps lock recipes inside a proprietary cloud. Werb takes the opposite stance:
- Your recipes are plain BeerJSON 2.x files on your disk.
- Brew sessions are plain JSON files next to them.
- Every calculation has a JSON Schema you can audit.
- Sync to GitHub is opt-in and explicit — one repo, one Personal Access Token, one click per push or pull.
If Werb disappears tomorrow, your data is still BeerJSON — readable in every other brewing tool.
What’s in this guide
- Installation — download the desktop app, open the PWA, or install the CLI.
- The CLI —
werb convertandwerb validate. Use it to bulk-import historical recipes, or to schema-check a folder before pushing. - Sync to a GitHub repo — point Werb at a repo and let it write per-recipe
.beerjsonfiles into a folder you choose. - Architecture — for contributors and the curious.
- BeerJSON schema notes — what Werb supports, what it deliberately doesn’t, and where the two diverge from upstream.
Quick links
Installation
Werb runs three ways from the same codebase. Pick whichever fits.
Web app
Open https://werb-dev.github.io/werb/ in any modern browser. The page is a Progressive Web App — your browser will offer an “Install” button (or “Add to Home Screen” on iOS/iPadOS) that turns it into a standalone app with its own dock icon. All data stays in the browser’s origin private file system; nothing leaves the device unless you opt into GitHub sync.
Tested against iPad Air 2 / iOS 15.8.4 for the older Safari corner cases — the PWA path is the only realistic choice on iPad today.
Desktop app
Download the bundle for your platform from the latest release:
| Platform | Asset |
|---|---|
| macOS (Apple Silicon) | Werb_*_aarch64.dmg |
| Linux | werb_*_amd64.deb or werb_*_amd64.AppImage |
| Windows | Werb_*_x64-setup.exe or Werb_*_x64_en-US.msi |
Intel Macs are no longer supported as of v0.2 — the release matrix targets Apple Silicon only. Use the web PWA on Intel hardware.
CLI
werb convert and werb validate for batch work from the shell — see The werb CLI for prebuilt binaries and a cargo install path.
From source
Requirements: Node.js 20+, pnpm, Rust toolchain (for the BeerXML WASM crate), and for desktop builds also a Tauri toolchain.
git clone --recurse-submodules https://github.com/werb-dev/werb.git
cd werb
pnpm install
pnpm gen:types # generate TS types from JSON Schemas
pnpm test # full suite, ~430 tests
# Web dev:
pnpm -F @werb/desktop dev
# Desktop dev (Tauri):
pnpm -F @werb/desktop tauri:dev
# Production web build:
pnpm -F @werb/desktop build
The vendor/beerjson/ submodule is required — it’s where the validator and the Rust type generator read the BeerJSON 2.x schemas from. If you cloned without --recurse-submodules, run git submodule update --init --recursive.
The werb CLI
werb is a standalone command-line tool for working with recipe files outside the GUI. It currently exposes two subcommands:
werb convert— bulk-convert a mix of BeerXML and BeerJSON inputs into the canonical “one recipe per.beerjsonfile” layout.werb validate— schema-validate one or more recipe files against the BeerJSON 2.x schema.
Both commands take the same input shapes (single files, multiple files, or directories) and share the same parser that the desktop app uses, so anything werb convert accepts will import cleanly in the GUI too.
Install
Prebuilt binaries
Every release attaches CLI archives. Download the one for your platform from the latest release:
| Platform | Asset |
|---|---|
| macOS Apple Silicon | werb-aarch64-apple-darwin.tar.gz |
| macOS Intel | werb-x86_64-apple-darwin.tar.gz |
| Linux x86_64 | werb-x86_64-unknown-linux-musl.tar.gz (fully static, runs on any glibc/musl distro) |
| Windows x86_64 | werb-x86_64-pc-windows-msvc.zip |
Extract, drop werb somewhere on your PATH, done.
From source
If you have a Rust toolchain:
cargo install --git https://github.com/werb-dev/werb werb-cli
This produces a binary named werb in ~/.cargo/bin/.
Verify the install
$ werb --version
werb 0.2.0
$ werb --help
Werb's recipe toolbox
Usage: werb <COMMAND>
Commands:
convert Convert one or more BeerXML or BeerJSON files into per-recipe `.beerjson` files
validate Validate one or more recipe files against the BeerJSON 2.x schema
help Print this message or the help of the given subcommand(s)
werb convert
Normalizes mixed recipe sources into a uniform per-recipe BeerJSON layout. Useful for:
- Bootstrapping a GitHub recipes archive from historical files.
- Translating BeerXML exports from BeerSmith / Brewfather / joliebulle into something Werb (and every other modern tool) reads.
- Splitting a multi-recipe BeerJSON document into one file per recipe.
Usage
werb convert [OPTIONS] <INPUTS>...
<INPUTS> is one or more files or directories. Directories are scanned non-recursively for .xml, .beerxml, and .beerjson files. Examples:
# Single file
werb convert ~/Downloads/Blanche.xml -o ./recipes
# A directory full of mixed XML and JSON exports
werb convert ~/old-recipes/ -o ./recipes
# Mix-and-match — file paths and directories side by side
werb convert ./fresh.beerxml ~/historical/ ./one-off.beerjson -o ./recipes
Output
For each recipe found in the inputs, werb convert writes a <slug>.beerjson file into the output directory. The slug is a kebab-cased, ASCII-normalized version of the recipe’s name — "Bière de Garde" becomes biere-de-garde.beerjson. The same slugify rules are used by the in-app GitHub sync, so a recipe round-trips between the CLI and the app under the same filename.
A typical run looks like:
$ werb convert ~/Downloads -o ./recipes
wrote ./recipes/blanche.beerjson
wrote ./recipes/smash-amarillo.beerjson
2 written · 0 skipped
Flags
| Flag | Purpose |
|---|---|
-o, --output <DIR> | Output directory. Created if it doesn’t exist. Defaults to the current working directory. |
--dry-run | Print what would be written, touch no disk. Useful for previewing slug rules before committing. |
--force | Overwrite output files that already exist. Without this flag, an on-disk collision gets a -2, -3, … suffix (blanche-2.beerjson). |
Slug collisions
Two recipes with the same name slugify to the same filename. werb convert resolves this in two passes:
- Within a single invocation, every recipe gets a unique slug — second-and-later collisions are suffixed
-2,-3, … So a folder with twoBlancherecipes producesblanche.beerjsonandblanche-2.beerjson. - Against the on-disk state, the same suffixing applies unless
--forceis passed, in which case the existing file is overwritten.
werb validate
Schema-validates recipe files against the vendored BeerJSON 2.x schemas. For .beerjson inputs, it parses to JSON and runs the schema. For .beerxml inputs, it parses via the BeerXML reader, runs the typed converter, then validates the output — answering “would Werb import this cleanly?”.
The schemas are embedded into the werb binary at compile time, so the command works offline and on any machine without checking out the source tree.
Usage
werb validate [OPTIONS] <INPUTS>...
# One file
werb validate ./recipes/blanche.beerjson
# A folder full of mixed XML and JSON
werb validate ./recipes/ ./drafts/
Output
One line per file with a clear ✓ / ✗ marker. Failures include the JSON pointer to the offending field and a human-readable reason:
$ werb validate ./recipes
✓ ./recipes/blanche.beerjson
✓ ./recipes/smash-amarillo.beerjson
✗ ./recipes/typo.beerjson
jsonschema validation failed with .../beer.json#
- at '/beerjson/recipes/0' [recipe.json#/definitions/RecipeType]: validation failed
- at '/beerjson/recipes/0/style/category_number' [style.json#/.../type]: want integer, but got string
3 valid · 1 invalid
Exit code is 0 when every file validated and 1 if any failed — pipeline-friendly.
Flags
| Flag | Purpose |
|---|---|
--fail-fast | Stop on the first invalid file instead of validating every input. Useful in CI when the goal is a fast green/red signal rather than a full report. |
Use cases
Bootstrap a recipe archive on GitHub
Werb sync is configured to read a folder of .beerjson files. The CLI is the fastest way to populate that folder from a pile of historical exports:
git clone git@github.com:you/beer-recipes.git
cd beer-recipes
mkdir -p recipes
werb convert ~/Downloads/*.xml ~/old-recipes/ -o recipes
git add recipes && git commit -m "import historical recipes" && git push
Then in Werb → Settings → GitHub, connect to your repo with recipes as the folder, and Pull. Every file shows up in the app.
CI for a recipes-only repo
If the repo above is just a recipe collection (no source code), drop a tiny CI workflow that validates every recipe on every push:
# .github/workflows/validate.yml
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: curl -L https://github.com/werb-dev/werb/releases/latest/download/werb-x86_64-unknown-linux-musl.tar.gz | tar xz
- run: ./werb-x86_64-unknown-linux-musl/werb validate ./recipes --fail-fast
Any commit that breaks the schema fails the check before it reaches anyone’s local copy.
Sanity-check a BeerSmith export before importing
$ werb validate ~/Documents/BeerSmith\ Exports/*.xml --fail-fast
✓ ~/Documents/BeerSmith Exports/IPA-2025.xml
✗ ~/Documents/BeerSmith Exports/lager-draft.xml
/beerjson/recipes/0/style/category_number: want integer, but got string
0 valid · 1 invalid
Knowing the file is broken before you import beats hunting down why the recipe came in with no style.
Sync to a GitHub repo
Werb syncs recipes to a GitHub repository of your choice, one file per recipe. The repo browses like a curated cookbook on github.com, and edits produce legible diffs on commit — the file-driven story from the README.
What’s synced
- Recipes, as one
<slug>.beerjsonfile per recipe under a folder you pick (defaultrecipes/).
What’s not synced
- Brew sessions, equipment profiles, unit preferences. They stay on this device.
Setting it up
- Create a fine-grained Personal Access Token on GitHub with
Contents: read+writescoped to one repo. - In Werb → Settings → GitHub: paste the token, the
owner/repo, the branch, and the recipes folder. - Hit Verify & connect.
- Push writes every local recipe to the folder. Pull reads every file from the folder. The “Overwrite local recipes” checkbox decides what happens when a recipe name collides.
Bootstrapping with the CLI
If you have a pile of historical recipes in BeerXML or BeerJSON, the werb CLI is the fastest way to populate the repo before connecting Werb to it.
Architecture
Werb runs three ways — web PWA, desktop (Tauri), CLI — from a single workspace. Most code lives in shared packages; only the thinnest shims at the edges differ between runtimes.
Workspace layout
werb/
├─ apps/
│ └─ desktop/ React + Vite PWA, also wrapped by Tauri
│ ├─ src/ UI screens, state hooks, storage backends
│ └─ src-tauri/ Rust shim for the native desktop wrapper
│
├─ crates/ Rust libraries + CLI
│ ├─ werb-beerxml/ BeerXML 1.0 parser + joliebulle v4 importer
│ │ + BeerXML→BeerJSON converter
│ ├─ werb-beerxml-wasm/ wasm-bindgen shim exposing the parsers to JS
│ ├─ werb-beerjson/ BeerJSON 2.x types, generated from the schema
│ └─ werb-cli/ The `werb` binary (convert, validate, …)
│
├─ packages/ JS/TS workspace packages
│ ├─ calc/ Brewing math — pure functions, schema I/O
│ ├─ adapters/ BeerJSON ↔ calc input bridges
│ ├─ types/ TS types generated from JSON Schemas
│ └─ validate/ ajv-based BeerJSON 2.x validator
│
├─ schemas/ Werb's own JSON Schemas (equipment, session, calc i/o)
├─ vendor/beerjson/ Git submodule — upstream BeerJSON 2.x schemas
│
└─ docs/ This site, source for mdBook
Data flow on import
.beerxml ─┐
.xml ─┤
┌─ parseBeerXmlJson ──┐
│ │
.json ─┬─→ ├─ parseJoliebulleJson┤ ──→ BeerJsonRecipe[] ──→ StorageBackend
.beerjson┘ │ │ │
└─ JSON.parse ────────┘ ↓
OPFS / Tauri fs /
localStorage
The single Import recipes button in the Library screen sniffs the file’s content (XML by < prefix, joliebulle by {recipes:[…]} no envelope, BeerJSON by {beerjson:{…}}) and dispatches to the matching parser. Every parser hands back a typed werb_beerjson::Recipe; serde drives the JSON wire shape, so what hits storage validates against the BeerJSON 2.x schema at every layer.
Storage layers
Storage is abstracted behind a StorageBackend interface so the brewing logic doesn’t know whether it’s reading from disk, IndexedDB, or a remote API:
| Backend | When it’s used |
|---|---|
opfsBackend | Default in the browser PWA. Files live in the origin private file system. |
| Tauri filesystem | Default in the desktop app. Files live in the platform’s app-data directory. |
localStorageBackend | Fallback when neither of the above is available (older browsers). |
gitHubBackend | Opt-in GitHub sync. Not the live backend — used only for push/pull buttons. |
Schemas are the contract
Three places in the codebase validate against the same schemas:
- The ajv validator in
@werb/validate— runs at import time in the PWA. - The boon validator in Rust integration tests — runs on the
Recipe::to_beerjson()output every time the test suite runs. - The
werb validateCLI — same schemas, embedded into the binary at compile time.
All three read the same byte-for-byte files from vendor/beerjson/json/*.json (a git submodule pinning the upstream repo). The typed Rust model in werb-beerjson is also generated from those files via typify — a schema bump becomes a Rust compile error at every site that constructs an affected value.
Regenerate after a schema update:
git submodule update --remote vendor/beerjson
pnpm gen:beerjson # Rust types
pnpm gen:types # TS types for our own schemas
Calc engine
packages/calc/ is the brewing math: IBU (Tinseth), color (Morey), gravity, ABV, water volumes, mash strike, carbonation, yeast pitch, brewing-salt additions. Each module follows the same pattern:
- JSON Schema defines the input and output shapes (under
schemas/tools/<name>.input.schema.json). pnpm gen:typesproduces TypeScript interfaces from the schemas.- The function takes the typed input, returns the typed output. No I/O, no side effects.
- Tests cover both the happy path and reference-table values from brewing literature.
UI components consume the calc functions via adapters in packages/adapters/ that translate a BeerJSON recipe into the right calc input.
Why three runtimes from one codebase?
- The web PWA is the always-available path — iPad, Chromebook, any browser. Same code as desktop.
- The desktop app (Tauri) gets you a real native shell with proper file dialogs, system menus, and offline-first behaviour without a service worker.
- The CLI is for batch work the GUI can’t reasonably do — convert 232 recipes at once, validate a folder in CI, script imports.
The shared layer makes this cheap: the calc engine is one set of pure functions, the BeerJSON shape is one set of generated types, and the parsers live in crates/ so both the WASM shim and the CLI use the same code.
BeerJSON schema notes
How Werb handles BeerJSON 2.x — what it reads, what it writes, where it diverges, and why.
We vendor the schemas
The BeerJSON 2.x JSON Schema files live under vendor/beerjson/ as a git submodule pinning werb-dev/beerjson on the fix-packaging-vessel-graphics-items branch. Two upstream contributions are bundled there: a $ref + type: array packaging-vessel fix submitted as beerjson/beerjson#222, and a misc-additions root-level additionalProperties: false consistency fix. Once both land in beerjson/beerjson, we repoint the submodule at upstream master.
The vendoring keeps validation reproducible — every layer (the ajv validator in the PWA, the boon validator in the Rust tests, the CLI’s werb validate, the typify-generated werb-beerjson types) reads the same byte-for-byte schema set.
Strongly-typed model from schema
The werb-beerjson crate’s types are generated directly from the JSON Schema by typify. A schema bump becomes a Rust compile error at every site that constructs a now-invalid value — which is the whole point.
Regenerate after a schema update with pnpm gen:beerjson.
What Werb reads today
The single Import recipes button (and the CLI’s werb convert / werb validate) sniffs the file’s content and dispatches to the right parser. Supported on the way in:
| Format | How it shows up | Notes |
|---|---|---|
| BeerJSON 2.x | .beerjson (or .json with {"beerjson":{…}}) | Full schema; written back round-trip clean. |
| BeerXML 1.0 | .beerxml / .xml | Read-only via the WASM parser. Joliebulle v3 exports are BeerXML, so they land here. |
| Joliebulle v4 | .json with {"recipes":[…], "timestamp":…} | Custom JSON shape the v4 desktop app emits when you export your library. Translated to BeerJSON 2.x via the same typed converter. |
On the way out, Werb writes BeerJSON 2.x (round-trip clean) and BeerXML 1.0 (for sharing with tools that still need it).
Lossy by design
A few BeerXML fields have no place in BeerJSON 2.x (the recipe-level <IBU> value, hop addition notes, yeast addition temperature ranges) and are dropped on conversion. Their values are reconstructable from the recipe’s other content.
For joliebulle v4 specifically, the per-recipe id / nameId / pre-computed ratio fields are ignored — they’re useful to joliebulle internally but don’t translate.
Tolerated quirks (BeerXML)
Real-world BeerXML files don’t always follow the spec. Werb’s parser is deliberately lenient about the most common offenses, all of which show up in joliebulle v3 exports:
- Empty self-closing enum elements (
<TYPE />) on Recipe / Style / Yeast — treated as missing rather than as a parse error. - Missing
<AMOUNT>on yeast (one pack = one item, defaults to 0). <OG>/<FG>/<COLOR>at the recipe level instead of<EST_OG>/<EST_FG>/<EST_COLOR>— used as a fallback so the recipe’s gravity estimates aren’t silently lost.
Tolerated quirks (joliebulle v4)
The v4 JSON shape has its own conventions the parser papers over:
- Numbers as strings (
"alpha": "4","step_temp": "67.0") — accepted either way. - Empty strings,
"undefined", and"FALSE"as null markers — all collapse toNone. - Fermentable / hop / misc weights in grams instead of BeerXML’s kilograms — divided by 1000 on the way in.
- “Flame Out” hop use — mapped to BeerXML’s
Aroma(post-boil late addition).
If you find a file Werb can’t import, please file an issue with the file attached.
Contributing
Issues and pull requests welcome — see the README’s Contributing section for the contract-first workflow.
Editing these docs
Every page on this site has an Edit this page link in the top-right that opens the source file (docs/src/*.md) on GitHub with a fresh fork ready to commit. For larger changes, clone the repo and:
cargo install mdbook # one-time
cd docs
mdbook serve --open # live-preview at http://localhost:3000
mdBook hot-reloads on every save, so writing here feels close to writing in a notebook.
The docs site is built and deployed automatically alongside the PWA — every push to main rebuilds https://werb-dev.github.io/werb/docs/.