# philiprehberger-template
[](https://github.com/philiprehberger/rb-template/actions/workflows/ci.yml)
[](https://rubygems.org/gems/philiprehberger-template)
[](https://github.com/philiprehberger/rb-template/commits/main)
Logic-less Mustache-style template engine with safe rendering
## Requirements
- Ruby >= 3.1
## Installation
Add to your Gemfile:
```ruby
gem "philiprehberger-template"
```
Or install directly:
```bash
gem install philiprehberger-template
```
## Usage
```ruby
require "philiprehberger/template"
tpl = Philiprehberger::Template.new("Hello, {{name}}!")
tpl.render(name: "World")
# => "Hello, World!"
```
### Sections and Inverted Sections
```ruby
# Truthy/falsy sections
tpl = Philiprehberger::Template.new("{{#show}}visible{{/show}}")
tpl.render(show: true) # => "visible"
tpl.render(show: false) # => ""
# Array iteration
tpl = Philiprehberger::Template.new("{{#items}}* {{name}}\n{{/items}}")
tpl.render(items: [{ name: "Alice" }, { name: "Bob" }])
# => "* Alice\n* Bob\n"
# Inverted sections
tpl = Philiprehberger::Template.new("{{^items}}No items found.{{/items}}")
tpl.render(items: [])
# => "No items found."
# Nested scopes (child inherits parent variables)
tpl = Philiprehberger::Template.new("{{#user}}{{greeting}}, {{name}}{{/user}}")
tpl.render(greeting: "Hi", user: { name: "Alice" })
# => "Hi, Alice"
```
### Partials
```ruby
Philiprehberger::Template.register_partial("header", "
{{title}}
")
Philiprehberger::Template.register_partial("footer", "")
tpl = Philiprehberger::Template.new("{{> header}}{{content}}{{> footer}}")
tpl.render(title: "Home", content: "Welcome!", year: 2026)
# => "Home
Welcome!"
Philiprehberger::Template.clear_partials!
```
### Custom Delimiters
```ruby
tpl = Philiprehberger::Template.new("{{name}} {{= <% %> =}} <%greeting%>")
tpl.render(name: "Alice", greeting: "Hi")
# => "Alice Hi"
```
### Filters
```ruby
# Single filter
tpl = Philiprehberger::Template.new("{{name | upcase}}")
tpl.render(name: "hello")
# => "HELLO"
# Chained filters
tpl = Philiprehberger::Template.new("{{name | strip | upcase}}")
tpl.render(name: " hello ")
# => "HELLO"
# Default filter with argument
tpl = Philiprehberger::Template.new("{{name | default(Anonymous)}}")
tpl.render({})
# => "Anonymous"
# HTML escaping
tpl = Philiprehberger::Template.new("{{content | escape}}")
tpl.render(content: "")
# => "<script>alert('xss')</script>"
# Custom filters
Philiprehberger::Template::Filters.register("shout", ->(val) { "#{val}!!!" })
tpl = Philiprehberger::Template.new("{{name | shout}}")
tpl.render(name: "hello")
# => "hello!!!"
```
Built-in filters: `upcase`, `downcase`, `strip`, `escape`, `capitalize`, `reverse`, `length`, `default`, `truncate`, `titleize`.
```ruby
# Truncate filter (default limit: 30)
tpl = Philiprehberger::Template.new("{{text | truncate(10)}}")
tpl.render(text: "Hello, beautiful world")
# => "Hello, bea..."
# Titleize filter — capitalize each word
tpl = Philiprehberger::Template.new("{{title | titleize}}")
tpl.render(title: "hello world from ruby")
# => "Hello World From Ruby"
```
List custom filters that have been registered:
```ruby
Philiprehberger::Template::Filters.register("shout", ->(val) { "#{val}!" })
Philiprehberger::Template.registered_filters
# => ["shout"]
```
### Template Compilation and Caching
```ruby
# Compile once, render many times with different data
tpl = Philiprehberger::Template.compile("Hello, {{name}}!")
tpl.render(name: "Alice") # => "Hello, Alice!"
tpl.render(name: "Bob") # => "Hello, Bob!"
# Same source returns the cached template instance
tpl2 = Philiprehberger::Template.compile("Hello, {{name}}!")
tpl.equal?(tpl2) # => true
Philiprehberger::Template.clear_cache!
```
### Template Inheritance/Layouts
```ruby
Philiprehberger::Template.register_layout("base", <<~LAYOUT)
{{$ title}}Default Title{{/title}}
{{$ body}}Default Body{{/body}}
LAYOUT
tpl = Philiprehberger::Template.new("{{< base}}{{$ title}}My Page{{/title}}{{$ body}}Hello!{{/body}}{{/base}}")
tpl.render({})
# Renders layout with "My Page" as title and "Hello!" as body
Philiprehberger::Template.clear_layouts!
```
### Lambda Support
```ruby
tpl = Philiprehberger::Template.new("{{#bold}}text{{/bold}}")
tpl.render(bold: ->(raw) { "#{raw}" })
# => "text"
# Lambdas receive the raw (unrendered) block text
tpl = Philiprehberger::Template.new("{{#wrap}}{{name}}{{/wrap}}")
tpl.render(name: "Alice", wrap: ->(raw) { "[#{raw}]" })
# => "[{{name}}]"
```
### Comments
```ruby
# Comments are stripped from rendered output
tpl = Philiprehberger::Template.new("Hello{{! This is a comment }} World")
tpl.render({})
# => "Hello World"
# Multi-line comments
tpl = Philiprehberger::Template.new("Hello{{! this is\na multi-line comment }}World")
tpl.render({})
# => "HelloWorld"
```
### Strict Mode
```ruby
# Raises UndefinedVariableError for missing variables
tpl = Philiprehberger::Template.new("Hello, {{name}}!", strict: true)
tpl.render(name: "World") # => "Hello, World!"
tpl.render({}) # => raises UndefinedVariableError
# Raises UndefinedFilterError for unknown filters
tpl = Philiprehberger::Template.new("{{name | bogus}}", strict: true)
tpl.render(name: "hi") # => raises UndefinedFilterError
# Default mode renders empty string for missing variables
tpl = Philiprehberger::Template.new("Hello, {{name}}!")
tpl.render({})
# => "Hello, !"
```
### Whitespace Control
```ruby
# Strip whitespace before the tag
tpl = Philiprehberger::Template.new("Hello {{~ name }}")
tpl.render(name: "World")
# => "HelloWorld"
# Strip whitespace after the tag
tpl = Philiprehberger::Template.new("{{ name ~}} there")
tpl.render(name: "Hello")
# => "Hellothere"
# Strip both sides
tpl = Philiprehberger::Template.new("Hello {{~ name ~}} World")
tpl.render(name: ", ")
# => "Hello, World"
```
## API
| Method | Description |
|--------|-------------|
| `Template.new(source, strict: false)` | Compile a template string into a renderable template |
| `Template.from_file(path, strict: false)` | Read a file and compile its contents as a template |
| `Template.compile(source, strict: false)` | Compile and cache a template for repeated rendering |
| `Template.register_partial(name, source)` | Register a named partial template |
| `Template.clear_partials!` | Remove all registered partials |
| `Template.register_layout(name, source)` | Register a named layout template |
| `Template.clear_layouts!` | Remove all registered layouts |
| `Template.registered_partials` | List names of all registered partials |
| `Template.registered_layouts` | List names of all registered layouts |
| `Template.registered_filters` | List names of all registered custom filters |
| `Template.clear_cache!` | Clear the compiled template cache |
| `Template.cache` | Access the template cache instance |
| `Filters.register(name, callable)` | Register a custom filter |
| `Filters.reset_custom!` | Remove all custom filters |
| `#render(variables = {})` | Render the template with the given variable hash |
| `#source` | Returns the original template source string |
| `#strict?` | Returns whether the template uses strict mode |
### Thread Safety
Note: `Template.register_partial`, `Template.register_layout`, and the compilation cache are class-level shared state. If you register partials or layouts from multiple threads simultaneously, wrap the calls in a Mutex.
## Development
```bash
bundle install
bundle exec rspec
bundle exec rubocop
```
## Support
If you find this project useful:
⭐ [Star the repo](https://github.com/philiprehberger/rb-template)
🐛 [Report issues](https://github.com/philiprehberger/rb-template/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
💡 [Suggest features](https://github.com/philiprehberger/rb-template/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
❤️ [Sponsor development](https://github.com/sponsors/philiprehberger)
🌐 [All Open Source Projects](https://philiprehberger.com/open-source-packages)
💻 [GitHub Profile](https://github.com/philiprehberger)
🔗 [LinkedIn Profile](https://www.linkedin.com/in/philiprehberger)
## License
[MIT](LICENSE)