Lini
Pretty diagrams from plain text, with fine-grained control.
cat -> dog -> bird
One line is a complete diagram: three boxes, two arrows, sensible spacing. You place the boxes, Lini routes the wires, and the same syntax scales up to the polished scene below.
Thirty-odd lines of Lini (samples/hero.lini).
Why Lini
Most tools make you choose: draw by hand (precise, but tedious and hard to version) or auto-layout everything (fast, but you take what the algorithm gives). Lini splits the work: you keep spatial control, it automates only the wires.
- You arrange, it routes. Place nodes with flex, grid, or anchors; Lini routes the connectors between them: orthogonal, clearance-respecting, deterministic.
- The full range of SVG. Sizes, anchors, strokes, shadows, rotation, opacity, raw paths: full control over the look, not a fixed house style, so a diagram can actually look good.
- A small syntax. Two brackets —
{ }for style,[ ]for children — plus a few sigils and sensible defaults.cat -> dogis already a valid diagram; the whole language is small enough to learn in one sitting. - Any shape you need. 12 primitives and 10 templates, plus a raw
paththat accepts any SVG path string. If SVG can draw it, you can place it and wire to it. - Fast, and one file. A 1.5 MB native binary, one runtime dependency. No Node, JVM, or headless browser; typical diagrams compile in about 2 ms, startup included.
- Reproducible output. Compilation is byte-identical across runs, so renders diff cleanly and never churn in CI. 408 tests back it, including property tests on the router's laws.
- Pretty by default. A curated 11-hue palette in soft pastels — four OKLCH-tuned tiers each — plus angle-less gradients, all themeable and dark/light-aware. No hex codes required.
- Dark mode, automatically. Every colour is a
light-dark()CSS variable, so one SVG carries both palettes and follows the viewer's light/dark OS setting on its own — or adata-themetoggle. Re-theme from the page with no recompile, choose a built-in (light, dark, high-contrast), or bake any palette into a standalone file.
Install
|
Building from a clone instead? cargo install --path .
A tour of Lini
Names become boxes; -> connects them. Line styles and fans mix freely:
cat -> dog -> bird // a chain: three boxes, two arrows
fox & owl -> mouse // fan-in
frog ~> pond // wavy
fish --> bowl // dashed
A diagram reads like a CSS file. A { } stylesheet at the top sets defaults, declares reusable classes, and extends shapes; then come the instances, then the wires:
{ // the stylesheet — pure setup, draws nothing
-> { stroke: #444; clearance: 10; }
.loud { stroke: red; stroke-width: 2; }
|db::cyl| { fill: lightyellow; } // a new shape from the cylinder primitive
}
api |box| "API"
queue |box| .loud { radius: 8 } "Queue" // a node wears its class after the type
store |db| "Postgres"
api -> queue "enqueue"
queue -> store .loud "persist" // …and a wire wears it the same way
store ..> api "ack" // dotted arrow
Containers lay their children out. Style sits in { }, children in [ ]; pick a mode and they flow, grid, or anchor:
services |group| { layout: row; gap: 24 } [
|caption| "Services"
api |box| "API"
auth |box| "Auth"
]
row, column, and grid (sized by columns / rows, placed with cell: / span:), plus pin and translate to lift a child out of the flow.
Shapes
|hex| { width: 82; height: 72 } "hex"
|cyl| { width: 78; height: 78 } "db"
|poly| { points: 0 -34, 32 11, 20 34, -20 34, -32 11; }
|path| { path: "M -34 6 C -34 -34 34 -34 34 6 C 20 34 -20 34 -34 6 Z"; }
Box, oval, hex, slant, cylinder, diamond, cloud, polygon, line, icon (Material Symbols), and image, plus path for anything else. Text is not a shape: a bare "…" is content, and |plain| is a frameless box for a label that needs an id or a wire. Templates (plain, rect, group, caption, footer, badge, note, row, column, table) bundle common patterns, and you can define your own from any base: |panel::group| { stroke: --accent; }.
Wires
Connect two nodes by id and Lini finds an orthogonal path through the free space, keeping a configurable clearance from every node and wire, rounding the corners, and landing the arrowhead on the edge. One knob (clearance, default 16) sets spacing for the whole diagram.
The operator is the wire's look, written [start][line][end] with no spaces:
| Line | Markers | ||
|---|---|---|---|
- solid |
-- dashed |
> arrow |
* dot |
.. dotted |
~ wavy |
< crow |
<> diamond |
So -> is a solid arrow, <-> is bidirectional, --* a dashed line ending in a dot, ~> a wavy arrow. Endpoints support fan-out, fan-in, and cartesian fans with &, and dot-paths into nested containers (closet.outlet -> fridge.inlet). Routing is automatic but steerable: name a side (a.right -> b.left) to force where a wire leaves or arrives. Labels ride the wire and slide to clear nodes; the wire never moves for a label.
The full routing contract (crossings, priority, self-loops, starvation) lives in WIRING.md.
Colour
Pretty by default. A curated palette of 11 named hues — red rose orange amber lime green teal sky blue purple gray — each in four job-named tiers, so the easy path is the flattering one:
{ |card::box| { fill: --teal-wash; stroke: --teal-ink } } // a soft card, one line
note |note| { fill: --amber-soft }
hero |box| { fill: gradient(--rose, --amber, --sky) } // a three-colour blend
- Four tiers per hue —
wash(palest, for backgrounds),soft, the bare name (the everyday pastel), andink(deep, for strokes and text). The names hold across the dark flip:--teal-inkis the high-contrast tone in both modes, where alight/darkname would invert. - OKLCH under the hood, so the ramp is perceptually even and the eleven read as a family. Pick any colour the same way —
fill: oklch(0.7, 0.14, 200)— and conventional names still land (--yellow→ amber,--pink→ rose). - Gradients —
gradient(--rose, --sky)blends two hues at a flattering angle (any two look good); add stops for a multi-colour wash,linear-gradient(135, …)for a custom angle, orradial-gradient(…). Works on fill and stroke. - Everything flips and bakes. Hues and gradient stops are
light-dark()variables, so a colour follows dark/light like the rest and freezes to a literal under--bake-vars. Only the colours a diagram uses are emitted, so a big palette never bloats a small file.
Theming
One SVG, both palettes. Every colour is a light-dark() pair, so an exported SVG carries both and switches on its own — it follows the viewer's OS (prefers-color-scheme) with no script or @media, and a data-theme="dark"/"light" attribute on the SVG or any ancestor overrides it.
Defaults sit in @layer lini.defaults, so unlayered host CSS wins — no !important, no rebuild:
} /* recolour every diagram on the page */
Geometry is always baked in, so a theme only ever changes colour — layout never depends on the host.
Three built-in themes — light, dark, and high-contrast — pin a single palette at export time:
lini theme NAME prints any as a ready-to-edit --lini-* file; --bake-vars flattens it to literals for non-browser renderers (resvg, librsvg) and email. Every lini-* class is a stable styling hook.
The default font is a monospace stack (ui-monospace, "SF Mono", …, monospace): it reads crisp and keeps text sizing accurate. Swap it with --lini-font-family in the diagram, a theme, or the page's CSS.
The CLI
lini [options] <input.lini>
lini fmt [--check] [--stdout] <input.lini>
lini serve [--port N] [--bake-vars] [PATH]
lini desugar <input.lini>
lini theme [NAME]
| Flag | Meaning |
|---|---|
-o, --output FILE |
Output path (default: stdout). |
--format svg|html |
Raw SVG (default), or wrapped in a minimal HTML page. |
--bake-vars |
Inline var() references — for resvg, librsvg, raster, email. |
--theme NAME|FILE |
A built-in theme (dark, high-contrast, …), a CSS file, or a light/dark pair. |
--check |
Parse and validate only. |
--watch |
Recompile on every change (with -o). |
--no-warn / --strict |
Silence lint warnings, or promote them to errors. |
Errors are LSP-formatted (file:line:col: error: …) and suggest fixes: an unknown endpoint asks did you mean kitchen.counter.bowl?. lini serve runs a live preview at localhost:7700 — a single file, or a folder as a playground; lini desugar prints a file with its sugar expanded, for teaching and debugging.
Playground
lini serve is also a browser playground. Point it at a folder and it lists the .lini files inside; pick one from the dropdown to open it in a small editor — source on the left, the diagram rendering live on the right.
Syntax highlighting, a draggable split, and light/dark themes (it follows your system by default). Ctrl/Cmd-S renders the current buffer; Save writes it back to the file.
Performance
Measured end-to-end on a modern laptop, including process startup (--bake-vars, output discarded):
| Diagram | Time |
|---|---|
| One node | ~1.6 ms |
| Realistic service diagram (9 nodes, 5 wires) | ~2.2 ms |
| Dense scene (100 nodes, 90 routed wires) | ~50 ms |
A single-pass parser, bottom-up layout, and an orthogonal router. No browser to spin up.
Where Lini fits
| Lini | Auto-layout tools* | |
|---|---|---|
| Placement | you control (flex / grid / anchors) | automatic |
| Wire routing | automatic, orthogonal — steerable sides | automatic |
| Visual control | full SVG (CSS vars + classes) | theme presets |
| Runtime | single native binary, written in Rust | varies (Node, browser, JVM, …) |
*the common auto-layout diagram tools (Mermaid, Graphviz, PlantUML, and the like)
Reach for Lini when you have a layout in mind (a grid, a top-down flow, framed groups) and want it to look the way you intend, without drawing the connectors by hand. Placing everything for you is a non-goal: you arrange, Lini routes.
Architecture
A linear pipeline, each stage independently testable:
lex → parse → resolve → layout → route → render
Parsing is recursive-descent over an LL(1) grammar; resolve applies CSS-like specificity (inline beats class beats default) and expands user shapes; layout sizes bottom-up; the router solves wires orthogonally against a clearance contract; render emits semantic SVG. The full language is specified in SPEC.md.
Status
v0.7. The language (the box/text model in SPEC.md) is stable, and the pipeline is complete and tested: wires route and render, layout and theming work, and the formatter and dev server ship in the same binary.
Non-goals, by design: automatic node placement, multi-file imports, animation, and manual wire waypoints.
Development
samples/ holds a .lini per feature area; tests/conformance.rs snapshots their SVG with insta, and tests/wiring.rs asserts the router's laws on every scene.
License
MIT — see LICENSE.