Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 CLIwerb convert and werb 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 .beerjson files 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.

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:

PlatformAsset
macOS (Apple Silicon)Werb_*_aarch64.dmg
Linuxwerb_*_amd64.deb or werb_*_amd64.AppImage
WindowsWerb_*_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 .beerjson file” 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:

PlatformAsset
macOS Apple Siliconwerb-aarch64-apple-darwin.tar.gz
macOS Intelwerb-x86_64-apple-darwin.tar.gz
Linux x86_64werb-x86_64-unknown-linux-musl.tar.gz (fully static, runs on any glibc/musl distro)
Windows x86_64werb-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

FlagPurpose
-o, --output <DIR>Output directory. Created if it doesn’t exist. Defaults to the current working directory.
--dry-runPrint what would be written, touch no disk. Useful for previewing slug rules before committing.
--forceOverwrite 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:

  1. Within a single invocation, every recipe gets a unique slug — second-and-later collisions are suffixed -2, -3, … So a folder with two Blanche recipes produces blanche.beerjson and blanche-2.beerjson.
  2. Against the on-disk state, the same suffixing applies unless --force is 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

FlagPurpose
--fail-fastStop 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>.beerjson file per recipe under a folder you pick (default recipes/).

What’s not synced

  • Brew sessions, equipment profiles, unit preferences. They stay on this device.

Setting it up

  1. Create a fine-grained Personal Access Token on GitHub with Contents: read+write scoped to one repo.
  2. In Werb → Settings → GitHub: paste the token, the owner/repo, the branch, and the recipes folder.
  3. Hit Verify & connect.
  4. 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:

BackendWhen it’s used
opfsBackendDefault in the browser PWA. Files live in the origin private file system.
Tauri filesystemDefault in the desktop app. Files live in the platform’s app-data directory.
localStorageBackendFallback when neither of the above is available (older browsers).
gitHubBackendOpt-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 validate CLI — 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:

  1. JSON Schema defines the input and output shapes (under schemas/tools/<name>.input.schema.json).
  2. pnpm gen:types produces TypeScript interfaces from the schemas.
  3. The function takes the typed input, returns the typed output. No I/O, no side effects.
  4. 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:

FormatHow it shows upNotes
BeerJSON 2.x.beerjson (or .json with {"beerjson":{…}})Full schema; written back round-trip clean.
BeerXML 1.0.beerxml / .xmlRead-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 to None.
  • 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/.