crinkle
crinkle
A Jinja2-compatible template engine for Crystal with lexer, parser, renderer, formatter, and linter.
Installation
Add to your shard.yml:
dependencies:
crinkle:
github: nobodywasishere/crinkle
Quick Start
require "crinkle"
# Parse and render a template
source = "Hello, {{ name }}!"
lexer = Crinkle::Lexer.new(source)
parser = Crinkle::Parser.new(lexer.lex_all)
template = parser.parse
renderer = Crinkle::Renderer.new
output = renderer.render(template, {"name" => Crinkle.value("World")})
# => "Hello, World!"
Supported Jinja2 Features
Expressions
- Variables:
{{ name }} - Attribute access:
{{ user.name }},{{ item["key"] }} - Filters:
{{ name | upper }},{{ items | join(", ") }} - Tests:
{% if value is defined %},{% if items is sequence %} - Operators:
+,-,*,/,//,%,**,==,!=,<,>,<=,>=,and,or,not,in,is - Literals: strings, numbers, booleans, lists
[1, 2, 3], dicts{"a": 1} - Ternary:
{{ "yes" if active else "no" }}
Control Structures
{% if %}/{% elif %}/{% else %}/{% endif %}{% for item in items %}/{% else %}/{% endfor %}{% set name = value %}{% macro name(args) %}/{% endmacro %}{% call %}/{% endcall %}{% block name %}/{% endblock %}{% extends "base.html" %}{% include "partial.html" %}{% import "macros.html" as m %}{% from "macros.html" import macro_name %}{% raw %}/{% endraw %}
Whitespace Control
- Trim whitespace:
{%- ... -%},{{- ... -}},{#- ... -#}
Comments
{# This is a comment #}
Standard Library
Crinkle includes a comprehensive standard library with 46 filters, 31 tests, and 6 functions. The standard library is loaded by default but can be disabled for custom environments.
Built-in Filters
String Filters:
upper,lower,capitalize,title- Case manipulationtrim- Remove leading/trailing whitespacetruncate(length, killwords=false, end="...")- Shorten stringsreplace(old, new)- String replacementwordcount- Count wordsreverse- Reverse string or arraycenter(width)- Center string with paddingindent(width, first=false)- Indent text
List Filters:
first,last- Get first/last itemjoin(separator)- Join array itemslength- Get size of string, array, or hashsort(reverse=false)- Sort arrayunique- Remove duplicatesbatch(size, fill_with=nil)- Group into batchesslice(slices, fill_with=nil)- Divide into slicessum(attribute=nil, start=0)- Sum numeric valuesmap(attribute)- Extract attribute from objectsselect(test)- Filter items by testreject(test)- Reject items by testselectattr(attr, test)- Filter by attribute testrejectattr(attr, test)- Reject by attribute testdefault(value, default_value=false)- Fallback value
Number Filters:
int(default=0),float(default=0.0)- Type conversionabs- Absolute valueround(precision=0, method="common")- Round numbersmin,max- Get minimum/maximumpow(exponent)- Power operation
HTML Filters:
escape,e- Escape HTML entitiessafe- Mark string as safe (no escaping)striptags- Remove HTML tagsurlize(trim_url_limit=nil, nofollow=false)- Convert URLs to linksurlencode- URL-encode string
Serialization Filters:
tojson(indent=nil)- Convert to JSONpprint- Pretty-print JSONlist- Convert to arraystring- Convert to stringattr(name)- Get attribute by namedictsort(case_sensitive=false, by="key", reverse=false)- Sort dictionaryitems- Get key-value pairs
Built-in Tests
Type Tests:
defined,undefined- Check if variable existsnone- Check if nilboolean,true,false- Boolean checksnumber,integer,float- Numeric type checksstring- String checksequence,iterable,mapping- Collection checksodd,even- Parity checksdivisibleby(n)- Divisibility check
Comparison Tests:
eq(value),equalto(value)- Equalityne(value)- Inequalitylt(value),lessthan(value)- Less thangt(value),greaterthan(value)- Greater thanle(value),ge(value)- Less/greater or equalin(container)- Containment check
String Tests:
lower,upper- Case checksstartswith(prefix),endswith(suffix)- String prefix/suffix
Built-in Functions
range(stop),range(start, stop),range(start, stop, step)- Generate sequencesdict(**kwargs)- Create dictionarynamespace(**kwargs)- Create namespace object for statelipsum(n=5, html=true, min=20, max=100)- Generate lorem ipsumcycler(*items)- Create cycling iteratorjoiner(sep=", ")- Create joining helper
Selective Loading
By default, all standard library features are loaded. You can disable this for minimal or custom environments:
# Load all standard library (default)
env = Crinkle::Environment.new
# Empty environment (no standard library)
env = Crinkle::Environment.new(load_std: false)
# Selectively load specific modules
env = Crinkle::Environment.new(load_std: false)
Crinkle::Std::Filters::Strings.register(env) # Only string filters
Crinkle::Std::Tests::Types.register(env) # Only type tests
Crinkle::Std::Functions::Range.register(env) # Only range function
Available Modules
Filters:
Crinkle::Std::Filters::Strings- String manipulationCrinkle::Std::Filters::Lists- List/array operationsCrinkle::Std::Filters::Numbers- Numeric operationsCrinkle::Std::Filters::Html- HTML escaping and manipulationCrinkle::Std::Filters::Serialize- Serialization operations
Tests:
Crinkle::Std::Tests::Types- Type checkingCrinkle::Std::Tests::Comparison- ComparisonsCrinkle::Std::Tests::Strings- String checks
Functions:
Crinkle::Std::Functions::Range- Range generationCrinkle::Std::Functions::Dict- Dictionary creationCrinkle::Std::Functions::Debug- Debug utilities
CLI
crinkle <command> [path ...] [options]
Commands
| Command | Description |
|---|---|
lex |
Tokenize template, output tokens + diagnostics |
parse |
Parse template, output AST + diagnostics |
render |
Render template, output HTML + diagnostics |
format |
Format template source |
lint |
Lint template, output diagnostics |
Options
| Option | Description |
|---|---|
--stdin |
Read template from stdin |
--format json|text|html|dot |
Output format (default varies by command) |
--pretty |
Pretty-print JSON output |
--no-color |
Disable ANSI colors |
--strict |
Treat warnings as errors |
--max-errors N |
Limit number of diagnostics |
--snapshots-dir PATH |
Write snapshot files |
Examples
# Lex a template
crinkle lex template.html.j2 --format json --pretty
# Format all templates in current directory
crinkle format
# Lint with strict mode
crinkle lint templates/*.j2 --strict
# Render from stdin
echo "Hello {{ name }}" | crinkle render --stdin
Value Serialization
Wrap Crystal values for use in templates:
# Basic values
Crinkle.value("string")
Crinkle.value(42)
Crinkle.value(true)
# Collections
Crinkle.value({"key" => "value"})
Crinkle.value([1, 2, 3])
# Build a context
context = Crinkle.variables({
"user" => {"name" => "Ada", "active" => true},
"items" => [1, 2, 3]
})
Custom Objects
Expose Crystal objects to templates with Crinkle::Object::Auto:
class User
include Crinkle::Object::Auto
@[Crinkle::Attribute]
def name
"Ada"
end
@[Crinkle::Attribute]
def admin?
true
end
end
context = {"user" => Crinkle.value(User.new)}
# Template: {{ user.name }}, admin: {{ user.is_admin }}
Methods ending with ? are automatically exposed as is_* (e.g., admin? becomes is_admin).
JSON and YAML
JSON::Any and YAML::Any work directly in templates:
data = JSON.parse(%q({"name": "Ada", "scores": [95, 87, 92]}))
context = {"data" => Crinkle.value(data)}
# Template: {{ data.name }}, first score: {{ data.scores[0] }}
Special Values
| Type | Description |
|---|---|
SafeString |
Pre-escaped HTML that won't be double-escaped |
Undefined |
Missing values (renders empty, tracks name for diagnostics) |
StrictUndefined |
Raises on any access (for strict mode) |
# Mark HTML as safe
Crinkle::SafeString.new("<strong>bold</strong>")
# Explicit undefined with name tracking
Crinkle::Undefined.new("missing_var")
Custom Extensions
You can extend Crinkle with custom filters, tests, and functions. All extensions are registered on an Environment instance.
Custom Filters
Filters transform values in templates using the pipe syntax: {{ value | filter_name(args) }}
env = Crinkle::Environment.new
# Simple filter with no arguments
env.register_filter("shout") do |value, args, kwargs|
value.to_s.upcase + "!"
end
# Usage: {{ "hello" | shout }} => "HELLO!"
# Filter with positional arguments
env.register_filter("truncate") do |value, args, kwargs|
length = args.first?.as?(Int64) || 50_i64
str = value.to_s
str.size > length ? str[0...length.to_i] + "..." : str
end
# Usage: {{ text | truncate(20) }}
# Filter with keyword arguments
env.register_filter("pad") do |value, args, kwargs|
width = args.first?.as?(Int64) || 10_i64
char = kwargs["char"]?.to_s || " "
value.to_s.ljust(width.to_i, char[0])
end
# Usage: {{ name | pad(20, char=".") }}
# Filter working with arrays
env.register_filter("multiply") do |value, args, kwargs|
factor = args.first?.as?(Int64) || 2_i64
case value
when Array
result = Array(Crinkle::Value).new
value.each { |item| result << item }
factor.times { result += value }
result.as(Crinkle::Value)
else
value
end
end
# Usage: {{ [1, 2, 3] | multiply(2) }} => [1, 2, 3, 1, 2, 3]
# Filter working with hashes
env.register_filter("get_keys") do |value, args, kwargs|
case value
when Hash(String, Crinkle::Value)
result = Array(Crinkle::Value).new
value.each_key { |k| result << k }
result.as(Crinkle::Value)
else
Array(Crinkle::Value).new
end
end
# Usage: {{ {"a": 1, "b": 2} | get_keys }} => ["a", "b"]
Filter Signature:
value: The value being filteredargs: Array of positional argumentskwargs: Hash of keyword arguments- Return: Must return a
Crinkle::Value
Custom Tests
Tests return boolean values for conditional checks: {% if value is test_name %}
env = Crinkle::Environment.new
# Simple test
env.register_test("even") do |value, args, kwargs|
case value
when Int64
value.even?
else
false
end
end
# Usage: {% if count is even %}
# Test with arguments
env.register_test("multiple_of") do |value, args, kwargs|
divisor = args.first?.as?(Int64)
return false unless divisor
case value
when Int64
value % divisor == 0
else
false
end
end
# Usage: {% if count is multiple_of(5) %}
# Test working with strings
env.register_test("palindrome") do |value, args, kwargs|
str = value.to_s
str == str.reverse
end
# Usage: {% if word is palindrome %}
# Test working with arrays
env.register_test("contains") do |value, args, kwargs|
search = args.first?
case value
when Array
value.includes?(search)
when String
value.includes?(search.to_s)
else
false
end
end
# Usage: {% if items is contains("target") %}
Test Signature:
value: The value being testedargs: Array of positional argumentskwargs: Hash of keyword arguments- Return: Must return a
Bool
Custom Functions
Functions are called directly and can create new values: {{ function_name(args) }}
env = Crinkle::Environment.new
# Simple function
env.register_function("greet") do |args, kwargs|
name = args.first?.to_s || "World"
"Hello, #{name}!"
end
# Usage: {{ greet("Ada") }}
# Function with keyword arguments
env.register_function("make_user") do |args, kwargs|
name = kwargs["name"]?.to_s || "Anonymous"
age = kwargs["age"]?.as?(Int64) || 0_i64
{
"name" => name,
"age" => age,
} of String => Crinkle::Value
end
# Usage: {% set user = make_user(name="Ada", age=25) %}
# Function returning arrays
env.register_function("repeat") do |args, kwargs|
value = args.first?
times = args[1]?.as?(Int64) || 1_i64
result = Array(Crinkle::Value).new
times.times { result << value }
result.as(Crinkle::Value)
end
# Usage: {{ repeat("item", 3) }}
# Generator function
env.register_function("fibonacci") do |args, kwargs|
n = args.first?.as?(Int64) || 10_i64
result = Array(Crinkle::Value).new
a, b = 0_i64, 1_i64
n.times do
result << a
a, b = b, a + b
end
result.as(Crinkle::Value)
end
# Usage: {% for num in fibonacci(8) %}{{ num }}{% endfor %}
Function Signature:
args: Array of positional argumentskwargs: Hash of keyword arguments- Return: Must return a
Crinkle::Value
Organizing Custom Extensions
For larger projects, organize extensions into modules:
module MyApp::Templates
module Filters
def self.register(env : Crinkle::Environment)
env.register_filter("currency") do |value, args, kwargs|
amount = value.as?(Int64 | Float64) || 0
"$%.2f" % amount
end
env.register_filter("slugify") do |value, args, kwargs|
value.to_s.downcase.gsub(/[^a-z0-9]+/, "-").strip("-")
end
end
end
module Tests
def self.register(env : Crinkle::Environment)
env.register_test("admin") do |value, args, kwargs|
case value
when Hash(String, Crinkle::Value)
value["role"]?.to_s == "admin"
else
false
end
end
end
end
end
# Register all custom extensions
env = Crinkle::Environment.new
MyApp::Templates::Filters.register(env)
MyApp::Templates::Tests.register(env)
Custom Tags
env.register_tag("note", ["endnote"]) do |parser, start_span|
parser.skip_whitespace
args = [parser.parse_expression([Crinkle::TokenType::BlockEnd])]
end_span = parser.expect_block_end("Expected '%}' to close note tag.")
body, body_end = parser.parse_until_any_end_tag(["endnote"], allow_end_name: true)
Crinkle::AST::CustomTag.new(
"note",
args,
[] of Crinkle::AST::KeywordArg,
body,
parser.span_between(start_span, body_end || end_span)
)
end
Pass the environment to the parser:
parser = Crinkle::Parser.new(tokens, env)
Formatter
Format templates with HTML-aware indentation:
source = "{%if x%}<div>{{y}}</div>{%endif%}"
formatter = Crinkle::Formatter.new(source)
formatted = formatter.format
# => "{% if x %}\n <div>{{ y }}</div>\n{% endif %}"
Options
options = Crinkle::Formatter::Options.new(
indent_string: " ", # Indentation (default: 2 spaces)
max_line_length: 120, # Target line length
html_aware: true, # Align with HTML structure
space_inside_braces: true, # {{ x }} vs {{x}}
)
formatter = Crinkle::Formatter.new(source, options)
Diagnostics
All passes (lexer, parser, renderer, linter) emit diagnostics with precise source locations:
lexer = Crinkle::Lexer.new(source)
tokens = lexer.lex_all
lexer.diagnostics.each do |diag|
puts "#{diag.severity}: #{diag.message} at #{diag.span.start_pos.line}:#{diag.span.start_pos.column}"
end
Diagnostic severities: Error, Warning, Info, Hint
Development
crystal spec # Run tests
crystal build src/cli/cli.cr -o crinkle # Build CLI
Note on Development
This project was vibe coded using GPT-5.2-Codex and Claude Opus 4.5.
Roadmap
See planning/PLAN.md for the development roadmap.
License
MIT
crinkle
- 4
- 0
- 0
- 0
- 0
- 1 day ago
- January 29, 2026
Sat, 31 Jan 2026 20:13:02 GMT