Views & Templates
Views handle the presentation layer of your application. Soli uses a familiar, expressive template syntax that combines HTML with dynamic logic.
ERB Syntax
Soli uses ERB-style tags. Use <%= ... %> for HTML-escaped output, <%- ... %> for raw (unescaped) output, and <% ... %> for logic like loops or conditionals.
Template Syntax
Tag Reference
| Tag | Behavior | When to use |
|---|---|---|
| <%= expr %> | HTML-escaped output | Default choice — anything from user input, params, the database. |
| <%- expr %> | Raw, unescaped output | Trusted HTML you've already produced — partials, Markdown.to_safe_html(...) output. |
| <% stmt %> | Executes code, emits nothing | let bindings, if, for, other statements. |
| <%= yield %> | Layout insertion point | Only inside a layout — marks where the rendered view is spliced in. |
| <%# comment %> | Nothing — stripped at parse time | Developer comments. Never sent to the browser. Single-line and multi-line both work. |
<%== expr %> was removed (SEC-023). It decoded HTML entities and emitted the result raw, which silently re-created <script> from <script> whenever a value had been round-tripped through escape-encoded storage. Use <%= html_unescape(expr) %> for entity-decoded but escaped output, or <%- expr %> for trusted raw HTML.
Output Variables
<!-- Basic variable output (HTML-escaped) -->
<h1><%= title %></h1>
<p>Hello, <%= name %>!</p>
<!-- Accessing hash data -->
<p>User: <%= user["name"] %></p>
<!-- Expressions -->
<p>Total: $<%= price * quantity %></p>
<!-- Raw output: skip escaping (only for HTML you trust) -->
<article><%- rendered_markdown %></article>
<%- partial("shared/nav") %>
Controller instance fields are exposed to the view as bare locals, but you can also reference them with the same @ prefix you used in the controller — in a view, <%= @title %> falls back to the title local, so both forms render the same value. An @-name with no matching local renders as empty (nil), like any other absent template local.
<!-- @title mirrors the controller; identical to the bare local below -->
<h1><%= @title %></h1>
<h1><%= title %></h1>
<!-- Works through member access and method calls too -->
<p><%= @user.name %></p>
<p>Comments: <%= @comments.length %></p>
Control Flow
<!-- Conditionals -->
<% if user_logged_in %>
<span>Welcome back!</span>
<% else %>
<a href="/login">Login</a>
<% end %>
<!-- Loops -->
<ul>
<% for post in posts %>
<li><%= post["title"] %></li>
<% end %>
</ul>
Template Helper Functions
These helper functions are automatically available in all templates.
DateTime Functions
<!-- Get current timestamp -->
<%= datetime_now() %>
<!-- Format a timestamp with strftime -->
<%= datetime_format(post["created_at"], "%Y-%m-%d") %>
<%= datetime_format(post["created_at"], "%B %d, %Y") %>
<%= datetime_format(datetime_now(), "%A, %B %d, %Y at %H:%M") %>
<!-- Parse a date string to timestamp -->
<%= datetime_parse("2024-01-15") %>
<!-- Add/subtract time -->
<%= datetime_add_days(datetime_now(), 7) %> <!-- 7 days from now -->
<%= datetime_add_days(datetime_now(), -30) %> <!-- 30 days ago -->
<%= datetime_add_hours(datetime_now(), 2) %> <!-- 2 hours from now -->
<!-- Human-readable relative time -->
<%= time_ago(post["created_at"]) %> <!-- "5 minutes ago", "2 hours ago", etc. -->
<%= time_ago(post["updated_at"]) %>
<!-- Difference in seconds -->
<%= datetime_diff(start_time, end_time) %>
<!-- Localized date formatting (uses current I18n locale) -->
<%= l(post["created_at"]) %> <!-- short format: "01/15/2024" -->
<%= l(post["created_at"], "long") %> <!-- "January 15, 2024" -->
<%= l(post["created_at"], "full") %> <!-- "Monday, January 15, 2024" -->
<%= l(post["created_at"], "time") %> <!-- "10:30 AM" -->
<%= l(post["created_at"], "datetime") %> <!-- "01/15/2024 10:30 AM" -->
<%= l(post["created_at"], "%Y-%m-%d") %> <!-- custom strftime -->
| Function | Description |
|---|---|
| datetime_now() | Returns current Unix timestamp (UTC) |
| datetime_format(ts, fmt) | Format timestamp with strftime (e.g., "%Y-%m-%d", "%B %d, %Y") |
| datetime_parse(str) | Parse date string to timestamp (ISO 8601, RFC 3339) |
| datetime_add_days(ts, n) | Add n days to timestamp (negative to subtract) |
| datetime_add_hours(ts, n) | Add n hours to timestamp |
| datetime_diff(t1, t2) | Difference between timestamps in seconds (t1 - t2) |
| time_ago(ts) | Human-readable relative time ("2 hours ago", "3 days ago") |
| l(ts, format?) | Localized date format using current locale ("short", "long", "full", "time", "datetime", or strftime) |
Strftime Format Codes
Use these codes with datetime_format() or l() for custom formatting:
| Code | Description | Example |
|---|---|---|
| %Y | 4-digit year | 2024 |
| %y | 2-digit year | 24 |
| %m | Month (01-12) | 01 |
| %B | Full month name | January |
| %b | Abbreviated month | Jan |
| %d | Day of month (01-31) | 15 |
| %e | Day of month (space-padded) | 5 |
| %A | Full weekday name | Monday |
| %a | Abbreviated weekday | Mon |
| %H | Hour 24h (00-23) | 14 |
| %I | Hour 12h (01-12) | 02 |
| %M | Minute (00-59) | 30 |
| %S | Second (00-59) | 45 |
| %p | AM/PM | PM |
| %Z | Timezone name | UTC |
| %j | Day of year (001-366) | 015 |
| %W | Week number (00-53) | 03 |
| %% | Literal % | % |
<!-- ISO format -->
<%= datetime_format(ts, "%Y-%m-%d") %> <!-- 2024-01-15 -->
<%= datetime_format(ts, "%Y-%m-%dT%H:%M:%S") %> <!-- 2024-01-15T14:30:45 -->
<!-- Human-readable -->
<%= datetime_format(ts, "%B %d, %Y") %> <!-- January 15, 2024 -->
<%= datetime_format(ts, "%A, %B %e, %Y") %> <!-- Monday, January 15, 2024 -->
<!-- Time formats -->
<%= datetime_format(ts, "%H:%M") %> <!-- 14:30 (24h) -->
<%= datetime_format(ts, "%I:%M %p") %> <!-- 02:30 PM (12h) -->
<!-- Combined -->
<%= datetime_format(ts, "%b %d at %I:%M %p") %> <!-- Jan 15 at 02:30 PM -->
I18n Functions
<!-- Get current locale -->
<%= locale() %> <!-- "en", "fr", etc. -->
<!-- Set locale (usually done in controller) -->
<% set_locale("fr") %>
<!-- Translate a key -->
<%= t("hello") %>
<!-- Translate with fallback -->
<%= t("greeting", "Welcome!") %>
| Function | Description |
|---|---|
| locale() | Get current locale code (e.g., "en", "fr") |
| set_locale(code) | Set the current locale |
| t(key, fallback?) | Translate a key with optional fallback |
HTML Functions
<!-- HTML escaping for element bodies (prevent XSS) -->
<%= html_escape(user_input) %>
<%= h(user_input) %> <!-- shorthand -->
<!-- Attribute-value escaping (use inside HTML attributes) -->
<a title="<%= attr(post.title) %>">Read</a>
<!-- JavaScript string escaping (use inside <script> blocks) -->
<script>const user = "<%= j(current_user.name) %>";</script>
<!-- URL percent-encoding (query params / path segments) -->
<a href="/search?q=<%= url(query) %>">Search</a>
<!-- Strip HTML tags -->
<%= strip_html(post["content"]) %>
<!-- Sanitize HTML (remove dangerous tags/attributes) -->
<%= sanitize_html(user_content) %>
<!-- Unescape HTML entities -->
<%= html_unescape("<p>") %>
<!-- Substring (useful for truncating) -->
<%= substring(post["content"], 0, 100) %>...
Pick the helper that matches the output context. h() is for element bodies, attr() for attribute values, j() for JS string literals, url() for URL query/path parts. Using the wrong one — e.g. h() inside a <script> — leaves XSS gaps.
Utility Functions
<!-- Generate a range for loops -->
<% for i in range(1, 5) %>
<p>Item <%= i %></p>
<% end %>
<!-- Output: 1, 2, 3, 4 -->
<!-- Range with step parameter -->
<% for i in range(0, 10, 2) %>
<p>Even: <%= i %></p>
<% end %>
<!-- Output: 0, 2, 4, 6, 8 -->
<!-- Reverse range with negative step -->
<% for i in range(5, 0, -1) %>
<p>Countdown: <%= i %></p>
<% end %>
<!-- Output: 5, 4, 3, 2, 1 -->
<!-- Asset paths with cache busting -->
<link href="<%= public_path("css/app.css") %>" rel="stylesheet">
<script src="<%= public_path("js/app.js") %>"></script>
<!-- Output: /css/app.css?v=a1b2c3... -->
<!-- String concatenation -->
<%= "Hello, " + name + "!" %>
<%= "Total: $" + price * quantity %>
Request-Context Functions
Read fields off the current request directly — no need to plumb them through the view data hash. Available in every template (views, layouts, partials). They return null when called outside an active request (e.g. from a unit test).
current_path()
Request pathname, e.g. "/users". null outside a request.
current_method()
HTTP method, e.g. "GET". null outside a request.
current_path?(p)
true if the current path equals p exactly. Use for active-link checks.
<nav>
<a href="/users" class="<%= current_path?("/users") ? "active" : "" %>">Users</a>
<a href="/posts" class="<%= current_path?("/posts") ? "active" : "" %>">Posts</a>
</nav>
<p>You are viewing <%= current_path() %> (<%= current_method() %>).</p>
For prefix matches (e.g. any path under /users), compose with current_path().starts_with("/users").
Hover Preload
Soli auto-injects a small <script> tag into every HTML response that listens for mouseover on links and adds a <link rel="prefetch" as="document">, so the browser warms its document-prefetch cache before the user clicks. The script is served at /__soli/prefetch.js — an external file, not inline, so strict-CSP apps work out of the box. Browsers send these requests with a Sec-Purpose: prefetch header (older browsers Purpose: prefetch), so your backend can log or differentiate them.
Same-origin GET only. Cross-origin, mailto:, tel:, and in-page #fragment links are skipped.
65 ms hover debounce. Fly-over hovers don't waste bandwidth.
Respects Save-Data / 2G. Skipped on navigator.connection.saveData or slow networks.
Works on touch. touchstart triggers an immediate prefetch.
Opt out per link with data-no-prefetch:
<a href="/heavy-report" data-no-prefetch>Heavy Report</a>
<!-- Also skips everything inside the container -->
<section data-no-prefetch>
<a href="/a">A</a>
<a href="/b">B</a>
</section>
Opt out globally with an env var:
SOLI_PREFETCH=off soli serve .
off, false, 0, and no all disable; anything else (or unset) keeps it on.
Caching defaults: every HTML response from a controller — whether you call render(...) explicitly or let an OOP controller action auto-render its matching view — carries two headers automatically so the prefetch actually delivers instant navigation on click.
ETag: W/"<16-hex>"— content-derived weak validator (FNV-1a over the rendered body, computed after live-reload/prefetch script injection). Weak so it survives a CDN re-compressing the response (Cloudflare and friends strip strong ETags when they re-encode; weak ones pass through).Cache-Control: private, no-cache— browser may cache, shared caches (CDN, reverse proxy) may not; the entry must be revalidated before reuse.
On click, the browser sends If-None-Match: W/"<etag>". If the render would produce the same bytes, Soli short-circuits to 304 Not Modified with just the validator headers — no body re-transmission. The prefetched body is consumed as the navigation response, so the click feels instant.
Behind a CDN (Cloudflare, etc.): that 304 round-trip only works if the conditional GET reaches your origin. Some edge setups don't relay it — a “Cache Everything” rule, edge revalidation, or HTML-transform features (Rocket Loader, Email Obfuscation, Mirage) that need the full body — so the click re-downloads the whole page and the prefetch is wasted.
To stay robust regardless of edge config, Soli detects the Sec-Purpose: prefetch request and answers it with Cache-Control: private, max-age=30 instead of no-cache. The prefetched HTML is then fresh in the browser's own (private) cache for a short window, so the click reuses it directly — no conditional GET, so the CDN never gets a vote. Normal navigations don't carry the prefetch header, so they keep private, no-cache. Tune the window with SOLI_PREFETCH_TTL (seconds, clamped 1–300; default 30). If your CDN rewrites Cache-Control for the browser (Cloudflare's Browser Cache TTL set to anything but Respect Existing Headers), leave it on Respect Existing Headers for the app hostname so this max-age survives.
Override per response when the defaults don't fit. Set your own Cache-Control (and optionally ETag) in the response headers hash and the framework defaults step aside:
def downloads
# One-shot download — never reuse; always re-fetch.
return {
"status": 200,
"headers": { "Cache-Control": "no-store", "Content-Type": "text/csv" },
"body": csv_bytes
}
end
Gotchas:
Cache-Control: no-store(explicit, in your response) disables the cache entirely — prefetch still fires but the browser re-fetches on click. Use for sensitive one-shot pages.- POST/PUT/DELETE responses aren't cached regardless, so nothing special is needed there.
- Per-request
Set-Cookieheaders (flash messages, CSRF rotation) can cause some browsers to ignore the cache entry even with goodCache-Control. The prefetch still warms the TCP/TLS connection and server-side caches, so the click is at least faster.
Instant Navigation
On top of hover preloading, Soli auto-injects an instant-navigation script (served at /__soli/nav.js) into every HTML response. It intercepts same-origin link clicks, fetches the target page in the background, and swaps <body> in place — merging the new page's <title>, stylesheets, and meta tags — while managing the URL bar with pushState. The result is Turbo-Drive-style navigation: your CSS and JS stay loaded, Alpine and htmx stay booted, and clicks render near-instantly, all while the app remains plain server-rendered HTML.
When instant navigation is on, it takes over hover prefetching: a JavaScript fetch() can't consume <link rel="prefetch"> entries (browser cache partitioning), so nav.js prefetches hovered links into its own in-memory cache with the same ergonomics (65 ms debounce, touchstart, data-no-prefetch, save-data/2G skip). Its prefetch requests carry Purpose: prefetch, so all the Hover Preload caching machinery — SOLI_PREFETCH_TTL, the ETag/304 revalidation, the CDN notes — applies unchanged. SOLI_PREFETCH=off disables the hover warming without disabling click swapping.
Which clicks are intercepted? Only plain left-clicks on same-origin GET links. Everything else falls through to the browser:
New-tab intent. Modifier keys (Cmd/Ctrl/Shift/Alt), middle/right clicks, target="_blank", and download links are skipped.
Same-origin GET only. Cross-origin, mailto:/tel:, and <a data-method="post"> links fall through.
htmx owns its links. Anything with an hx-*/data-hx-* attribute or inside [hx-boost] is never intercepted — nor is a click your own handler already preventDefault()ed.
Native anchors. Same-page #fragment links keep the browser's native anchor scroll.
Opt out per link, per container, per page, or globally:
<!-- Per link, or per container -->
<a href="/legacy-page" data-no-nav>Legacy page</a>
<section data-no-nav> ... </section>
<!-- Per page (both the current page and any page navigated to) -->
<meta name="soli-nav" content="off">
# Globally — restores plain hover preloading (prefetch.js)
SOLI_NAV=off soli serve .
Lifecycle events fire on document so you can hook in:
| Event | Cancelable | When |
|---|---|---|
soli:visit |
yes — cancel to force a full navigation | Before a visit starts; detail.url |
soli:before-render |
yes — cancel to force a full navigation | After fetch, before the swap; detail.newDocument |
soli:load |
no | After every swap — the re-initialization hook |
DOMContentLoaded keeps working. The event itself fires once per document and never again after a swap — but inline scripts re-executed by a visit routinely register DOMContentLoaded/load listeners, so the framework replays them: once the event has already fired, registering a listener for it invokes the listener immediately (the same semantics as jQuery's .ready()). Your existing init code — sliders, lightboxes, anything wrapped in DOMContentLoaded — works after swaps without changes. The same replay covers alpine:init/alpine:initialized: a page-specific bundle first executed by a swap that registers components with document.addEventListener("alpine:init", () => Alpine.data(...)) works unchanged. For code in external scripts (which execute once per tab, not per visit), hook soli:load to re-initialize per navigation.
<script>
function initWidgets() { /* ... */ }
document.addEventListener("soli:load", initWidgets); // fires after every swap
initWidgets(); // first full load
</script>
Script semantics after a swap: inline <script> tags in the new body re-execute on every visit (that's what page-specific init wants). External <script src> tags execute once per URL for the lifetime of the browser tab — so alpine.min.js and htmx.min.js in your layout never double-evaluate. Scripts run sequentially in document order, each external awaited before the next script executes — the same guarantee the parser gives on a full load, so an inline tailwind.config = {...} right after the Tailwind CDN script still finds tailwind defined. Only after the whole chain settles does the framework call Alpine.initTree(document.body) and htmx.process(document.body) — so Alpine components whose x-data scope is registered by a page-specific bundle initialize correctly, and hx-* attributes in the new body just work.
Persistent elements (data-soli-permanent): the body swap tears down the old DOM and builds the new page fresh — fine for server-rendered content, but it destroys live client-side widgets that aren't reflected in the server HTML (a map with its rendered tiles, a playing <video>, a rich-text editor with unsaved state). Tag such an element with data-soli-permanent and an id, and the framework lifts the live element out of the old body and grafts it over the matching placeholder in the new one — untouched, never reparsed — so the widget keeps running across the navigation with no teardown, no re-initialization, no flicker:
<div id="gather-map" data-soli-permanent></div>
The element persists only between pages that both declare it (same id + data-soli-permanent); navigate to a page without the matching placeholder and it's discarded with the old body. Any inline <script> inside a permanent element is carried over live and is not re-executed on the swap (it already ran). This is the right tool when re-initializing on soli:load would mean an expensive rebuild — prefer it over the re-init hook for maps and media players.
History and scrolling: back/forward are handled via popstate with a refetch — which the ETag machinery answers with a cheap 304, served from the browser's HTTP cache. Scroll position is restored on back-navigation; forward visits scroll to the top (or to the #fragment target if the link had one).
View Transitions (opt-in): add the same meta tag Turbo uses and swaps animate with the View Transition API in supporting browsers:
<meta name="view-transition" content="same-origin">
Graceful degradation: non-HTML responses (downloads, JSON), fetch failures, and redirects that leave the origin all fall back to a normal full navigation automatically. (Alpine x-teleport pages swap fine — the swap destroys the old Alpine tree, which runs each teleport's cleanup, before replacing the body and re-initializing.) Error pages (404/500) that render HTML are swapped in like any other page, with the URL updated to the final response URL.
Application Helpers
When you create a new app with soli new, a starter helper file is generated at app/helpers/application_helper.sl. These helpers are automatically available in all templates.
Text Helpers
<!-- Truncate text with ellipsis -->
<%= truncate(post["content"], 100) %>
<!-- "This is a very long article that..." -->
<%= truncate(title, 50, " [more]") %>
<!-- "This is a long title that gets cut [more]" -->
<!-- Capitalize first letter -->
<%= capitalize("hello world") %>
<!-- "Hello world" -->
<%= capitalize(user["status"]) %>
<!-- "Active" (if status was "active") -->
<!-- Pluralize based on count -->
<%= pluralize(1, "item") %>
<!-- "1 item" -->
<%= pluralize(5, "item") %>
<!-- "5 items" -->
<%= pluralize(count, "person", "people") %>
<!-- "1 person" or "3 people" -->
Number & Currency Helpers (I18n)
<!-- Format numbers with thousands separators (default ",") -->
<%= number_with_delimiter(1234567) %>
<!-- "1,234,567" -->
<!-- Override delimiter manually -->
<%= number_with_delimiter(1234567, "'") %>
<!-- "1'234'567" -->
<!-- Format as currency (locale-aware: symbol, delimiter, position) -->
<%= currency(1000) %>
<!-- en: "$1,000" | fr: "1 000 €" | de: "1.000 €" | ja: "¥1,000" -->
<%= currency(order["total"]) %>
<!-- Override symbol manually -->
<%= currency(1234, "£") %>
<!-- "£1,234" -->
Locale-Aware Formatting
currency() uses the current I18n locale for its symbol, separators, and position.
Use set_locale("fr") in your controller to change formatting.
Supported: en, fr, de, es, it, pt, ja, zh, ru.
(number_with_delimiter() uses a fixed "," unless you pass a delimiter argument.)
Link Helper
<!-- Generate safe HTML links (XSS protected) -->
<%= link_to("Home", "/") %>
<!-- <a href="/">Home</a> -->
<%= link_to("View Profile", "/users/" + user["id"]) %>
<!-- <a href="/users/123">View Profile</a> -->
<%= link_to("Edit", "/posts/" + post["id"] + "/edit", "btn btn-primary") %>
<!-- <a href="/posts/456/edit" class="btn btn-primary">Edit</a> -->
URL Slug Helper
<!-- Convert text to URL-friendly slug -->
<%= slugify("Hello World!") %>
<!-- "hello-world" -->
<%= slugify("My Blog Post Title") %>
<!-- "my-blog-post-title" -->
<%= slugify("Café & Restaurant") %>
<!-- "cafe-restaurant" -->
<!-- Use in URLs -->
<a href="/posts/<%= slugify(post["title"]) %>">
<%= post["title"] %>
</a>
| Function | Description |
|---|---|
| truncate(text, length, suffix?) | Truncate text to length with suffix (default: "...") |
| capitalize(text) | Capitalize first letter of string |
| pluralize(count, singular, plural?) | Pluralize word based on count (default plural: singular + "s") |
| number_with_delimiter(num, delim?) | Format number with thousands separator (locale-aware) |
| currency(amount, symbol?) | Format as currency (locale-aware: symbol, delimiter, position) |
| link_to(text, url, class?) | Generate HTML link with XSS protection |
| slugify(text) | Convert text to URL-friendly slug |
| paginate(pagination, opts?) | Render pagination nav from Model.paginate result (links + window) |
| component(name, data?) | Render reusable component from components/<name>.html.slv |
Core pagination and component helpers are available in every template (no import needed):
paginate(pagination, opts?) renders nav links from a paginate result;
component(name, data?) renders from app/views/components/*.html.slv.
Use <%- %> for both.
Complete Example
<article class="post">
<h1><%= post["title"] %></h1>
<div class="meta">
<!-- Localized date (respects I18n.set_locale) -->
<span>Published: <%= l(post["created_at"], "long") %></span>
<span>(<%= time_ago(post["created_at"]) %>)</span>
</div>
<% if post["updated_at"] != post["created_at"] %>
<p class="updated">Last updated: <%= time_ago(post["updated_at"]) %></p>
<% end %>
<div class="content">
<%= truncate(post["content"], 500) %>
</div>
<div class="stats">
<span><%= number_with_delimiter(post["views"]) %> views</span>
<span><%= pluralize(post["comment_count"], "comment") %></span>
</div>
<div class="actions">
<%= link_to("Edit", "/posts/" + post["id"] + "/edit", "btn btn-secondary") %>
<%= link_to("Back to Posts", "/posts", "btn btn-link") %>
</div>
<footer>
<p>© <%= datetime_format(datetime_now(), "%Y") %> My Blog</p>
</footer>
</article>
Layouts
Layouts define the outer shell of your pages. Use <%= yield %> to inject the view content.
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
</head>
<body>
<nav>...</nav>
<main>
<%= yield %>
</main>
</body>
</html>
Named Content with content_for
A plain <%= yield %> gives the layout one insertion point. When a page needs to inject content elsewhere in the layout — a page-specific <script> in the <head>, a sidebar, extra meta tags — capture it in the view with content_for and read it back in the layout with a named yield.
<% content_for "head" do %>
<script src="/js/chart.js"></script>
<% end %>
<h1><%= report.title %></h1>
<head>
<title><%= title %></title>
<%= yield "head" %>
</head>
<body>
<%= yield %>
</body>
- Views and partials can capture — a
content_forblock inside a partial registers into the same store as the view that rendered it. - Repeated captures append — two
content_for "head"blocks concatenate in document order (Rails semantics). - Missing names render empty —
<%= yield "head" %>emits nothing when no view captured"head"; no guard needed. - No double-escaping — interpolations inside the block are escaped at capture time, and the named
yieldsplices the result raw, exactly like the mainyield. - Names are string literals (
"head"or'head'), so insertion points are known at parse time.
content_for("name") also works as a read-form in the layout, equivalent to yield "name". To wrap a section in markup only when something was captured, use the content_for? predicate — it returns true only when a non-empty capture exists:
<% if content_for?("sidebar") %>
<aside class="sidebar">
<%= yield "sidebar" %>
</aside>
<% end %>
Partials
Reuse components across views. Partials are named with a leading underscore.
<!-- Render _user_card.html.slv (partial() is the short alias for render_partial()) -->
<%= partial("partials/user_card", { "user": user }) %>
<!-- Equivalent: -->
<%= render_partial("partials/user_card", { "user": user }) %>
Inherited instance variables
Partials and components inherit the current controller's @instance variables, just like the main view — so a partial can read @current_user or @posts without passing them through the locals hash. An explicit local always wins over an inherited ivar of the same name, and framework internals (req, params, session, headers) are never injected.
The locals hash
Every partial receives its context hash as a variable named locals, mirroring Rails' local_assigns. Bare identifiers keep working for normal keys (<%= user %>); reach for locals["..."] when a key collides with a Soli reserved word (e.g. class) or a global builtin (e.g. type) — bare access would either fail to parse or resolve to the builtin function.
<%= partial("shared/icon", { "name": "bell", "class": "h-6 w-6" }) %>
<svg class="<%= locals["class"] %>" data-icon="<%= name %>">…</svg>
Missing keys return null, so the usual .nil? pattern applies with no extra guards:
<% let css = locals["class"].nil? ? "h-5 w-5" : locals["class"] %>
locals is always defined — even when a partial is rendered without a data hash it's an empty hash, so locals[anything] is safe.
Components
Components provide a dedicated convention and helper for reusable UI primitives. Use component() when you want to signal "this is a designed building block" rather than a generic fragment.
The component() helper
<%- component("stats_card", {
"label": "Active Users",
"value": 1243,
"trend": "+18%"
}) %>
Resolution rules:
- If the name contains
/or., it is used as a relative path. - Otherwise Soli looks for
components/<name>.html.slv.
Component files
<div class="stats-card">
<div class="label"><%= label %></div>
<div class="value"><%= number_with_delimiter(value) %></div>
<% if trend %>
<div class="trend"><%= trend %></div>
<% end %>
</div>
Subdirectories
Group related components:
app/views/components/
├── card.html.slv
├── badge.html.slv
├── table/
│ ├── header.html.slv
│ └── row.html.slv
└── form/
└── field.html.slv
<%= component("table/row", { "record": post }) %>
<%= component("form/field", { "name": "email" }) %>
Block syntax and slots
Use the block form for default slot (body) content. The body is automatically captured and available as content or via yield inside the component template.
<%- component "card", title: "Stats" do %>
Body content here
<%- end %>
Props come from named arguments (title: "Stats") or an explicit parenthesized hash (component("card", { "title": "Stats" }) do). A paren-less component "card", { … } do does not pass the hash.
Inside components/card.html.slv:
<div class="card">
<h3><%= title %></h3>
<div class="body"><%= yield %></div>
</div>
For named slots, use content_for "name" do inside the block and yield "name" in the template. Or bind a slot-builder with do |c| and call c.slot("name") at the top level of the block — it desugars to the same content_for capture:
<%- component "card", title: "With header" do |c| %>
<%- c.slot("header") do %><strong>Header</strong><%- end %>
Body
<%- end %>
Rendering a collection
Pass a "collection" to render the component once per item. Each item is bound to a local named after the component (override with "as"), plus <as>_index (0-based) and <as>_counter (1-based); other keys pass through to every item.
<%- component("post_card", { "collection": posts, "as": "post" }) %>
<!-- app/views/components/post_card.html.slv -->
<article data-n="<%= post_counter %>"><%= h(post["title"]) %></article>
Declaring props
Declare the props a component expects with props(...) at the top of the template. In --dev the renderer warns (dev bar Warnings panel + server console) about any declared prop the caller didn't provide; it's a no-op in production, and soli lint checks the declaration is well-formed (component/props). Optional props just aren't declared; inherited @ivars count as provided.
<% props("label", "value") %>
<div class="stat"><%= label %>: <%= number_with_delimiter(value) %></div>
Caching a component
Pass a "cache" option to memoize a component's rendered HTML in the KV cache. "cache": true derives the key from the data; a string sets an explicit key; "cache_ttl" sets the lifetime (seconds). Renders that set a cookie/session or read the clock/random are never cached, and a cache-backend outage falls back to a normal render.
<%- component("footer", { "cache": "site-footer", "cache_ttl": 3600 }) %>
Component catalog (dev)
With soli serve --dev, browse every component at /__soli/components — a Lookbook-style gallery listing each component, its declared props, and a live preview. Give a component example data with a leading <%# preview: {json} %> header. Previews render with built-in helpers plus the preview data; app-defined view helpers and request context aren't available there.
<%# preview: { "label": "Active", "value": 1243 } %>
<% props("label", "value") %>
<div><%= label %>: <%= number_with_delimiter(value) %></div>
Components vs partials
| Use case | Recommended |
|---|---|
| One-off extracted fragment | partial("thing", ...) |
| Named, reusable UI primitive | component("thing", ...) |
| Design-system pieces (cards, rows, badges) | component("job_row", ...) |
Both render the same way. The difference is convention and discoverability.
Best practices
- Keep components small and purely presentational.
- Start the file with a
#comment documenting expected keys. - Always use
h()for any user-supplied content. - Use descriptive names:
user_avatar,empty_state,job_row. - Group with subdirectories once you have more than ~8–10 components.
- Prefer
component()for anything that feels like a reusable widget.
Realistic example
# Expected locals:
# job: the job hash (id, script, status, priority, ...)
# status_class: optional CSS class
<tr class="job-row <%= status_class || "" %>">
<td class="font-mono"><%= job["id"] %></td>
<td><%= h(job["script"] || job["webhook_url"]) %></td>
<td><span class="status-pill"><%= job["status"] %></span></td>
<td class="text-right"><%= job["priority"] %></td>
<td>
<% if job["status"] == "pending" %>
<button class="btn-xs" hx-post="...">Run now</button>
<% end %>
</td>
</tr>
Used from a queue listing:
<% for job in @jobs %>
<%- component("job_row", {
"job": job,
"status_class": job["status"] == "failed" ? "is-failed" : ""
}) %>
<% end %>
Markdown Views
For content-heavy pages like documentation, you can write views as Markdown files instead of HTML templates.
Soli supports .md and .html.md extensions. The rendering pipeline processes template tags first, then compiles Markdown to HTML.
Rendering Pipeline
Markdown views follow the pipeline: template engine (<%= %> tags) → Markdown → HTML → layout.
This means you can use all the same template tags and helpers inside your .md files.
File Extensions
| Extension | Description |
|---|---|
| .html.md | Markdown view (preferred, explicit about HTML output) |
| .md | Markdown view (shorter form) |
Resolution priority: .html.slv > .slv > .html.md > .md > .html.erb > .erb
Example
# Getting Started with <%= app_name %>
Welcome! This guide will help you set up your project.
## Installation
Run the following command:
```bash
soli new my_app
cd my_app
soli serve
```
## Features
- **Hot reload** — changes appear instantly
- **ERB-style tags** — use `<%= %>` for dynamic content
- **Layouts** — Markdown views are wrapped in your layout
<% if show_advanced %>
## Advanced Configuration
| Option | Default | Description |
|--------|---------|-------------|
| port | 3000 | Server port |
| host | 0.0.0.0 | Bind address |
<% end %>
Supported Markdown Features
- Headings, paragraphs, bold, italic, links, images
- Ordered and unordered lists
- Fenced code blocks with language hints
- Tables (GitHub-flavored)
-
Strikethrough (
~~text~~) -
Task lists (
- [x] done)
Markdown Partials
Partials can also be Markdown files. Name them with a leading underscore like any partial.
<!-- In an .slv template, include a markdown partial -->
<%= partial("docs/intro") %>
<!-- This renders app/views/docs/_intro.md (or _intro.html.md) -->
Passing Data
Pass a hash of data from your controller to the view.
def show
post = Post.find(params["id"])
render("posts/show", {
"title": post["title"],
"post": post
})
end
Security Warning
Always use h() or html_escape() when outputting user-generated content to prevent XSS attacks.
Soli escapes output in <%= %> by default, but be cautious with raw output. For user-generated Markdown, use Markdown.to_safe_html(...) before rendering with <%- %>.
Best Practices
- Keep logic out of views. If it's complex, it belongs in a helper or controller.
- Use partials for small, reusable components like buttons, cards, or alerts.
- Organize views into folders matching your controller names.