Documentation

Everything you need to build and deploy a site with vanilo.

Install

curl -fsSL vanilo.ludovichorem.com/api/i|sh

One binary, no runtime, no dependencies. Works on Linux and macOS.

Commands

vanilo init my-site      # Create a new project
vanilo build             # Generate dist/
vanilo serve             # Build + dev server (auto-rebuild on save)
vanilo serve 8080        # Custom port

vanilo serve watches pages/, components/, static/, functions/, content/, layout.html and vanilo.toml. Changes trigger an automatic rebuild.

Project Structure

my-site/
  vanilo.toml          # Configuration
  layout.html          # Global layout
  pages/               # One .html = one route
    index.html         #   /
    about.html         #   /about
    docs/
      api.html         #   /docs/api
  components/          # Reusable (PascalCase)
    Card.html
    Header.html
  content/             # JSON data
    site.json
  static/              # CSS, JS, images
    style.css
  functions/           # Edge functions → /api/*
    notes.js           #   /api/notes
    users/
      list.ts          #   /api/users/list
  dist/                # Build output (don’t edit)
  data.db              # SQLite (auto-created)

Clean URLs: pages/about.html is served at /about.

Components

Create an .html file in components/. The filename is the tag name.

<!-- components/Card.html -->
<div class="card">
    <h3>{{title}}</h3>
    <p>{{description}}</p>
</div>

Use it in any page or component:

<!-- Self-closing -->
<Card title="Hello" description="World" />

<!-- With children (inserted before root closing tag) -->
<Card title="Hello">
    <Tag label="new" />
    <p>Extra content</p>
</Card>

Props: {{prop}} is HTML-escaped (safe). {{{prop}}} is raw (for trusted HTML). Unresolved props are silently removed.

Nesting works: components can contain other components, to any depth.

Layout

layout.html wraps every page. Page content is inserted before </body>. Use <meta> tags in pages to pass props to the layout.

<!-- layout.html -->
<!DOCTYPE html>
<html>
<head><title>{{title}} — My Site</title></head>
<body>
<!-- page content goes here -->
</body>
</html>

<!-- pages/about.html -->
<meta name="title" content="About">
<h1>About us</h1>

Content (JSON)

Each content/*.json file is accessible by filename. Use dot-paths to navigate.

// content/site.json
{
    "title": "My Site",
    "contact": { "email": "hi@example.com" }
}

// In any page or layout:
{{@site.title}}             → "My Site"
{{@site.contact.email}}     → "hi@example.com"

Array indices work too: {{@posts.0.title}} gets the first post’s title.

{{@...}} is HTML-escaped. {{{@...}}} is raw.

Each Loops

Iterate over a JSON array from content/:

<Each content="posts">
    <Card title="{{title}}" description="{{excerpt}}" />
</Each>

Nested paths work: content="site.team". Inside the loop, {{key}} refers to each object’s fields.

Edge Functions

A .js or .ts file in functions/ becomes an API endpoint.

// functions/notes.js → /api/notes

function handler(req) {
    // req.method   "GET", "POST", ...
    // req.path     "/api/notes"
    // req.body     request body (string)
    // req.query    query string (without "?")
    // req.headers  { name: value }

    return {
        status: 200,                   // optional, default 200
        headers: { "x-custom": "ok" }, // optional
        body: { message: "hello" }     // string or object
    };
}

Subdirectories map to nested routes: functions/users/list.ts/api/users/list.

TypeScript is supported natively. Type annotations are stripped at runtime.

SQLite

A built-in SQLite database is available in edge functions. Created on first use as data.db.

// Create
db.exec("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT)");

// Insert (always use ? params)
db.exec("INSERT INTO notes (text) VALUES (?)", ["Hello"]);

// Query
var rows = db.query("SELECT * FROM notes WHERE text LIKE ?", ["%" + q + "%"]);
// → [{ id: 1, text: "Hello" }, ...]

// Delete / Update
var result = db.exec("DELETE FROM notes WHERE id = ?", [1]);
// → { changes: 1 }

Always use ? parameters. String concatenation in SQL is blocked by the linter.

Outbound fetch()

Call external APIs from edge functions:

var res = fetch("https://api.example.com/data", {
    method: "POST",
    headers: { "Authorization": "Bearer token" },
    body: JSON.stringify({ key: "value" })
});
// res.status   HTTP status code
// res.body     response body (string)

Private IPs and localhost are blocked (SSRF protection). Configurable timeout via fetch_timeout.

Server Blocks (SSR)

Render dynamic content at request time inside a static page. The function runs, the result is rendered with a component, and the HTML is injected.

<!-- Basic -->
<Server function="notes" component="Card" />

<!-- With cache (seconds) -->
<Server function="notes" component="Card" cache="3600" />

<!-- Extra attributes become query params -->
<Server function="notes" component="Card" limit="5" />

If the function returns an array, the component is rendered for each item. If it returns an object, it’s rendered once.

Client Runtime

Reuse server components in the browser. The runtime is auto-injected only if your JS uses Vanilo.*.

// Render a list
Vanilo.list("#results", "Card", [
    { title: "First" },
    { title: "Second" }
]);

// Render one
Vanilo.put("#el", "Card", { title: "Hello" });

// Get raw HTML
var html = Vanilo.render("Card", { title: "Preview" });

// Escape HTML
var safe = Vanilo.esc("<script>");

Props are auto-escaped in all methods.

CSS Tree-Shaking

At build time, each page gets only the CSS rules it actually uses, inlined in a <style> tag.

Classes added by JavaScript won’t be in the static HTML. Safelist them:

/* vanilo:keep .modal .active .open */

Tags, classes (.prefix) and IDs (#prefix) are supported. Multiple safelist comments allowed.

@font-face, @keyframes, @charset, :root and * selectors are always preserved.

Configuration

All settings in vanilo.toml. Env vars PORT and HOST override it.

# vanilo.toml

port = 3000
host = "127.0.0.1"

# Server limits
max_body = 1              # MB
max_connections = 128
rate_limit = 60           # requests per window on /api/*
rate_window = 60          # seconds

# JS runtime
timeout = 5               # execution timeout (seconds)
memory = 32               # runtime memory (MB)
fetch_timeout = 10        # outbound HTTP timeout (seconds)

# Reverse proxy (for correct rate limiting)
# trusted_proxy = "127.0.0.1"

# JS minification (off by default)
# minify_js = true

# CORS for /api/*
# api_cors = ""                                    # same-origin (default)
# api_cors = "*"                                   # allow all
# api_cors = "https://a.com, https://b.com"        # specific origins

# Per-function overrides
[functions.my-function]
cors = "*"
rate_limit = 30
rate_window = 60

[security_headers]
content_security_policy = "default-src 'self'; style-src 'self' 'unsafe-inline'"
# strict_transport_security = "max-age=63072000; includeSubDomains"
# x_frame_options = "DENY"
# referrer_policy = "strict-origin-when-cross-origin"
# permissions_policy = "camera=(), microphone=(), geolocation=()"
# cross_origin_opener_policy = "same-origin"
# cross_origin_resource_policy = "same-origin"

[webhook]
# webhook_path = "/_hook/a3f8c91e..."
# webhook_secret = "your-secret"
# webhook_rate_limit = 5
# webhook_rate_window = 60

Set a header to "" to disable it. Priority: CLI args > env vars > vanilo.toml > defaults.

Webhook (Auto-Rebuild)

Trigger a rebuild on git push. Add the webhook URL in your GitHub/Gitea repo settings.

[webhook]
webhook_path = "/_hook/a3f8c91e..."    # generated by vanilo init
webhook_secret = "your-secret"

Vanilo verifies the HMAC-SHA256 signature (X-Hub-Signature-256), rebuilds in the background, and swaps the result with zero downtime.

Security Linter

The build fails if dangerous patterns are detected:

HTML (pages + components): props in event handlers (onclick...), javascript: URIs, props in <script> or style="".

JS (edge functions): SQL string concatenation, eval(), new Function(), document.write(), string-based setTimeout().

Deploy

vanilo init generates a Dockerfile and compose.yaml.

# Build and run
docker compose up -d

# Or manually
docker build -t my-site .
docker run -p 3000:3000 my-site

To persist SQLite data, mount the volume: ./data.db:/app/data.db.

For HTTPS, run behind Caddy or Nginx and set trusted_proxy in vanilo.toml.

Caching

Vanilo handles caching automatically:

HTML pages: no-cache (revalidates with ETag on each request).

Hashed assets (e.g. style.a1b2c3d4.css): immutable, cached 1 year. Hash changes when content changes.

Other static files: cached 1 day.

API responses: no-store.

All compressible files are pre-compressed at build time (gzip + brotli). API responses are compressed on the fly.

Limits

Single instance: SQLite and rate limiting are per-process. No horizontal scaling.

No live-reload: the server rebuilds on save but doesn’t refresh the browser.

No JS bundling: no module system, no import/require.

No incremental build: each build regenerates everything.

HTTP/1.1 only.