ESC
Type to search...
S
Soli Docs

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 &lt;script&gt; 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") %>

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("&lt;p&gt;") %>

<!-- 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 fires a same-origin fetch(), so the response is already in the browser's HTTP cache by the time 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. The prefetch request carries a Purpose: prefetch header so your backend can log or differentiate it if needed.

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 response out of render(...) carries two headers automatically so the prefetch actually delivers instant navigation on click.

  • ETag: "<16-hex>" — content-derived strong validator (FNV-1a over the rendered body, computed after live-reload/prefetch script injection).
  • 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: "<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.

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:

fn 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-Cookie headers (flash messages, CSRF rotation) can cause some browsers to ignore the cache entry even with good Cache-Control. The prefetch still warms the TCP/TLS connection and server-side caches, so the click is at least faster.

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 separator (auto-detects from locale) -->
<%= number_with_delimiter(1234567) %>
<!-- en: "1,234,567" | fr: "1 234 567" | de: "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

number_with_delimiter() and currency() automatically use the current I18n locale. Use set_locale("fr") in your controller to change formatting. Supported: en, fr, de, es, it, pt, ja, zh, ru.

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

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>&copy; <%= 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>

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 }) %>

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.

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 → HTMLlayout. 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.

Next Steps