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.