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 <%= ... %> to output values, and <% ... %> for logic like loops or conditionals.
Template Syntax
Output Variables
<!-- Basic variable output -->
<h1><%= title %></h1>
<p>Hello, <%= name %>!</p>
<!-- Accessing hash data -->
<p>User: <%= user["name"] %></p>
<!-- Expressions -->
<p>Total: $<%= price * quantity %></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 (prevent XSS) -->
<%= html_escape(user_input) %>
<%= h(user_input) %> <!-- shorthand -->
<!-- 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) %>...
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 %>
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>© <%= 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 -->
<%= render_partial("partials/user_card", { "user": user }) %>
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 -->
<%= render_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(req)
let post = Post.find(req.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.
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.