A lightweight template system for Go with support for variables, filters, conditionals, loops, and translations.
go get github.com/scorredoira/prosepackage main
import (
"fmt"
"github.com/scorredoira/prose"
)
func main() {
template := "Hello {{name|capitalize}}!"
context := map[string]any{"name": "john"}
tmpl := prose.MustParse(template)
result, _ := tmpl.Render(context, nil)
fmt.Println(result) // Output: Hello John!
}Syntax: {{variable_name}}
Features:
- Simple variables:
{{name}} - Nested properties:
{{user.address.city}} - Array access by index:
{{products[0].name}} - Array access with variable:
{{items[i]}}
Examples:
Hello {{customer_name}}
Order: {{order.number}}
Price: {{items[0].price}}
Dynamic: {{products[index].name}}
Behavior:
- Missing variables return empty string (no error)
Syntax: {{variable|filter}} or {{variable|filter1|filter2}}
| Filter | Description | Input Example | Output Example |
|---|---|---|---|
uppercase |
Convert to uppercase | hello |
HELLO |
lowercase |
Convert to lowercase | HELLO |
hello |
capitalize |
Capitalize each word | hello world |
Hello World |
format:c |
Currency format | 1234.56 |
$1,234.56 |
format:d |
Short date | 2025-10-15T10:30:00Z |
10/15/2025 |
format:g |
Date + time | 2025-10-15T10:30:00Z |
10/15/2025 10:30 AM |
format:f |
Float (2 decimals) | 1234.5678 |
1234.57 |
format:i |
Integer with separators | 1234567 |
1,234,567 |
Chaining filters:
{{name|lowercase|capitalize}}
{{price|format:c|uppercase}}
Custom filters:
opts := &prose.RenderOptions{
CustomFilters: map[string]prose.FilterFunc{
"reverse": func(value any, args ...string) (string, error) {
s := fmt.Sprintf("%v", value)
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes), nil
},
},
}Behavior:
- Filters apply left to right
- Unknown filters are silently ignored
- Filters handle nil/empty values safely
Syntax: {{t:translation_key}}
Behavior:
- Looks up
translation_keyin the translation dictionary for current language - Language specified in render options
- Missing keys return
[translation_key] - Missing language falls back to default (typically
en) - Translations can contain variables resolved after translation
Setup:
import "github.com/scorredoira/locale"
translations := locale.NewTranslationStore()
translations.Set("en", "welcome", "Welcome {{name}}!")
translations.Set("es", "welcome", "¡Bienvenido {{name}}!")
opts := &prose.RenderOptions{
Language: "es",
Translations: translations,
}
result, _ := tmpl.Render(context, opts)Translation format:
{
"welcome_message": {
"en": "Thank you for your purchase, {{customer_name}}!",
"es": "¡Gracias por tu compra, {{customer_name}}!",
"fr": "Merci pour votre achat, {{customer_name}}!"
},
"payment_due": {
"en": "Payment due: {{amount|format:c}}",
"es": "Pago pendiente: {{amount|format:c}}"
}
}Examples:
{{t:welcome_message}}
{{t:footer_copyright}}
{{t:payment_reminder}}
Basic syntax:
[IF condition]
content
[END]
With negation:
[IF NOT condition]
content
[END]
With else:
[IF condition]
content if true
[ELSE]
content if false
[END]
Variable existence:
[IF variable_name]
True if variable exists and is not false, null, 0, or empty string.
Comparisons:
| Operator | Description | Example |
|---|---|---|
= |
Equal | [IF status = "paid"] |
!= |
Not equal | [IF status != "pending"] |
> |
Greater than | [IF count > 5] |
>= |
Greater or equal | [IF count >= 5] |
< |
Less than | [IF count < 10] |
<= |
Less or equal | [IF count <= 10] |
Value types in comparisons:
- Numbers:
[IF age > 18] - Strings:
[IF status = "paid"](quotes required) - Booleans:
[IF is_active = true] - Variables:
[IF total > minimum]
Examples:
[IF has_discount]
Discount: {{discount_percentage}}%
[END]
[IF NOT is_paid]
Please complete payment.
[END]
[IF total > 100]
Free shipping!
[ELSE]
Shipping: $10
[END]
[IF status = "shipped"]
Your order is on the way!
[END]
[IF quantity >= minimum_order]
[IF has_express_shipping]
Ships today!
[ELSE]
Ships within 3 days
[END]
[END]
Behavior:
- IF blocks can be nested
- Spaces around operators are optional
- Blocks must close with
[END] [ELSE]is optional
Syntax:
[FOR item IN array]
content
[END]
Behavior:
- Empty arrays render nothing (no error)
- Missing variables render nothing (no error)
- Loops can be nested
- Access item properties:
{{item.property}}
Syntax:
[FOR i IN start..end]
content
[END]
Behavior:
- Ranges include both endpoints:
1..3produces 1, 2, 3 - Start must be ≤ end
- Only accepts integers
Available automatically inside any loop:
| Variable | Description | Type |
|---|---|---|
{{index}} |
1-based index | Number |
{{index0}} |
0-based index | Number |
{{is_first}} |
True on first iteration | Boolean |
{{is_last}} |
True on last iteration | Boolean |
Examples:
[FOR product IN products]
- {{product.name}}: {{product.price|format:c}}
[END]
[FOR i IN 1..5]
Day {{i}}: {{schedule[i]}}
[END]
[FOR item IN cart]
{{index}}. {{item.name}}[IF NOT is_last], [END]
[END]
[FOR order IN orders]
Order #{{order.id}}
[FOR item IN order.items]
{{index}}. {{item.name}}
[END]
[END]
[FOR day IN 1..7]
[IF day = 1]
Monday schedule
[ELSE]
Regular schedule
[END]
[END]
- Spaces and line breaks from original template are preserved
- No extra spaces added around substituted values
{{and}}always mark expression start/end[always marks control block start- No escape characters in this version
[IF]and[FOR]blocks can nest indefinitely- Each block must have its own
[END] [END]associates with nearest unclosed block
HTML/XML comments are preserved in output:
<!-- This is a comment -->
template := `{{t:email_header}}
Hello {{customer.name}},
[IF order.is_shipped]
Great news! Your order #{{order.number}} has been shipped.
Tracking: {{order.tracking_number}}
[ELSE]
Your order #{{order.number}} is being prepared.
[END]
Your items:
[FOR item IN order.items]
{{index}}. {{item.name}} - {{item.price|format:c}} x{{item.quantity}}
[END]
Total: {{order.total|format:c}}
[IF order.has_discount]
You saved {{order.discount_amount|format:c}} with your discount code!
[END]
{{t:email_footer}}`template := `Invoice #{{invoice.number}}
Date: {{invoice.date|format:d}}
[IF invoice.due_date]
Due: {{invoice.due_date|format:d}}
[END]
Bill to:
{{customer.name}}
{{customer.address}}
Items:
[FOR line IN invoice.lines]
{{index}}. {{line.description}}
{{line.quantity}} x {{line.unit_price|format:c}} = {{line.total|format:c}}
[END]
Subtotal: {{invoice.subtotal|format:c}}
[FOR tax IN invoice.taxes]
{{tax.name}} ({{tax.rate}}%): {{tax.amount|format:c}}
[END]
Total: {{invoice.total|format:c}}`
context := map[string]any{
"invoice": map[string]any{
"number": "INV-001",
"date": time.Now(),
"total": 150.00,
"subtotal": 140.00,
"lines": []map[string]any{
{"description": "Widget", "quantity": 2, "unit_price": 50.00, "total": 100.00},
{"description": "Gadget", "quantity": 1, "unit_price": 40.00, "total": 40.00},
},
"taxes": []map[string]any{
{"name": "Sales Tax", "rate": 7.5, "amount": 10.00},
},
},
"customer": map[string]any{
"name": "John Doe",
"address": "123 Main St, City, State 12345",
},
}
tmpl := prose.MustParse(template)
result, _ := tmpl.Render(context, nil)
fmt.Println(result)template := `{{t:reminder_greeting}}
[IF days_overdue > 0]
{{t:payment_overdue}}
[IF days_overdue > 30]
{{t:urgent_notice}}
[END]
[ELSE]
{{t:payment_upcoming}}
[END]
{{t:amount_label}}: {{amount|format:c}}
{{t:due_date_label}}: {{due_date|format:d}}
[IF payment_methods]
{{t:payment_methods_label}}:
[FOR method IN payment_methods]
- {{method.name}}: {{method.details}}
[END]
[END]
{{t:footer_signature}}`template := `Weekly Report: {{week_start|format:d}} - {{week_end|format:d}}
Daily Summary:
[FOR day IN 1..7]
Day {{day}}: {{daily_sales[day]|format:c}}
[END]
Top 5 Products:
[FOR i IN 1..5]
{{i}}. {{top_products[i].name}} - {{top_products[i].sales|format:c}}
[END]`template := `[FOR category IN categories]
{{category.name}}:
[FOR product IN category.products]
[IF product.in_stock]
{{index}}. {{product.name}} - {{product.price|format:c}}
[IF product.discount > 0]
SALE: {{product.discount}}% off!
[END]
[END]
[END]
[END]`The system rejects templates with:
- Unclosed blocks (
[IF]without[END]) [END]without corresponding opening block- Invalid condition syntax
- Invalid loop syntax
The system does NOT generate errors for:
- Missing variables → returns empty string
- Missing translations → returns
[key] - Empty arrays → skips loop
- Unknown filters → ignored
Only critical issues like infinite recursion or memory problems cause errors.
Built-in protection against:
- Stack overflow: Configurable
MaxDepthlimit (default: 100)opts := &prose.RenderOptions{MaxDepth: 50}
- Translation recursion: Maximum depth of 10 levels
- Malformed input: Parse-time validation catches issues early
- Thread-safe: Templates can be rendered concurrently
- Array bounds: Out-of-bounds access returns empty string (no panic)
- Nil values: Safely handled without crashes
Implement your own number/date formatting:
type MyLocalizer struct{}
func (l *MyLocalizer) Format(format string, value any) string {
// Your custom formatting logic
}
opts := &prose.RenderOptions{
Localizer: &MyLocalizer{},
}// For deeply nested templates
opts := &prose.RenderOptions{
MaxDepth: 200, // Increase from default 100
}| Element | Syntax | Example |
|---|---|---|
| Variable | {{name}} |
{{customer_name}} |
| Nested property | {{obj.prop}} |
{{order.total}} |
| Array index | {{arr[0]}} |
{{items[0].name}} |
| Variable index | {{arr[i]}} |
{{products[index]}} |
| Filter | {{var|filter}} |
{{price|format:c}} |
| Multiple filters | {{var|f1|f2}} |
{{name|lowercase|capitalize}} |
| Custom filter | {{var|format:name}} |
{{date|format:iso}} |
| Translation | {{t:key}} |
{{t:welcome}} |
| Conditional | [IF cond]...[END] |
[IF paid]Yes[END] |
| If-else | [IF cond]...[ELSE]...[END] |
[IF paid]Yes[ELSE]No[END] |
| Negation | [IF NOT cond]...[END] |
[IF NOT active]Inactive[END] |
| Comparison | [IF var = val] |
[IF status = "ok"] |
| Array loop | [FOR x IN arr]...[END] |
[FOR p IN products]{{p.name}}[END] |
| Range loop | [FOR i IN n..m]...[END] |
[FOR i IN 1..10]Day {{i}}[END] |
| Loop index | {{index}} or {{index0}} |
{{index}}. {{item}} |
| First iteration | {{is_first}} |
[IF is_first]First![END] |
| Last iteration | {{is_last}} |
[IF is_last]Last![END] |
Run tests:
go test -vRun benchmarks:
go test -bench=. -benchmemSee complete examples:
cd example
go run main.goBenchmarks on Apple M4 Pro:
- Parse: ~1.8 μs/op
- Simple render: ~240 ns/op
- Complex render (loops, conditionals): ~4.3 μs/op
- Parse + render: ~800 ns/op
Suitable for high-throughput applications.
MIT