Skip to content

lowlandtech/tinytools

Repository files navigation

Version Build Status Docs Status Documentation License: MIT Twitter: wendellmva

A lightweight template engine for .NET with minimal dependencies. Designed for data composition, not view rendering.


Why TinyTemplateEngine?

Most .NET templating solutions—RazorEngine, RazorLight, and similar—are built around one core assumption:

Templates are views, and views are primarily HTML.

That assumption becomes a liability once your problem is data composition, not UI rendering.

The Razor Problem

Razor excels at MVC-style view rendering, but it introduces friction when used as a general-purpose templating engine:

  • HTML-first design
    Razor tightly couples templates to HTML and view concepts, even when the output is not a web page.

  • Compile-time complexity
    Runtime compilation, Roslyn dependencies, caching layers, and AppDomain constraints add overhead for problems that don't require them.

  • Control-flow leakage
    Logic (@if, @foreach, helpers) creeps into templates, blurring the line between data preparation and data projection.

  • Poor fit for non-visual outputs
    Generating JSON, YAML, Markdown, config files, prompts, emails, or documents feels unnatural and verbose.

After migrating from RazorEngine to RazorLight, the core issue remained:
the templating model itself was working against the use case.

A Different Assumption

TinyTemplateEngine starts from a different premise:

A template is a projection of data — not a view, not a page, and not an application.

That shift enables a simpler and more predictable model.

What TinyTemplateEngine Optimizes For

What TinyTemplateEngine Optimizes For

  • Data-first templating
    Templates exist to merge structured data into text—nothing more.

  • Minimal surface area
    No compilation step, no HTML bias, no runtime code execution.

  • Explicit separation of concerns

    • Data is prepared outside the template

    • Templates only describe shape and placement

  • Format-agnostic output
    Works equally well for:

    • Text

    • Markdown

    • JSON / YAML

    • Config files

    • Prompts

    • Emails

    • Code generation

  • Predictable behavior
    No hidden execution model, no side effects, no magic.

When to Use TinyTemplateEngine

Use it when:

  • You are merging data into templates, not rendering views

  • You want templates that are safe, readable, and boring

  • You care more about composition and transformation than UI

  • Razor's power is getting in the way, not helping

If you need a full view engine, Razor is still the right tool.
If you need a small, deterministic templating engine, TinyTemplateEngine exists because that gap was real.

Install

dotnet add package LowlandTech.TinyTools

Usage

Basic String Interpolation

// Simple property interpolation with {PropertyName} syntax
var template = "Hello {FirstName} {LastName}!";
var model = new { FirstName = "John", LastName = "Smith" };

var result = template.Interpolate(model);
// Output: "Hello John Smith!"

Dictionary Interpolation

var template = "Welcome to {City}, {Country}!";
var data = new Dictionary<string, string>
{
    { "City", "Amsterdam" },
    { "Country", "Netherlands" }
};

var result = template.Interpolate(data);
// Output: "Welcome to Amsterdam, Netherlands!"

TinyTemplateEngine (Advanced)

var engine = new TinyTemplateEngine();
var context = new ExecutionContext();
context.Set("Name", "Alice");
context.Set("IsPremium", true);
context.Set("Items", new[] { "Item 1", "Item 2", "Item 3" });

var template = """
    Hello ${Context.Name}!
    
    @if (Context.IsPremium) {
    You have premium access.
    } else {
    Upgrade to premium for more features.
    }
    
    Your items:
    @foreach (var item in Context.Items) {
    - ${item}
    }
    """;

var result = engine.Render(template, context);

Using with Models

var engine = new TinyTemplateEngine();
var context = new ExecutionContext
{
    Model = new Customer
    {
        FirstName = "Jane",
        LastName = "Doe",
        Orders = new List<Order>
        {
            new Order { OrderNumber = "ORD-001", Total = "99.99" }
        }
    }
};

var template = """
    Dear ${Context.Model.FirstName} ${Context.Model.LastName},
    
    @foreach (var order in Context.Model.Orders) {
    Order #${order.OrderNumber} - Total: $${order.Total}
    }
    """;

var result = engine.Render(template, context);

Null Coalescing

var template = """
    Name: ${Context.Name ?? "Guest"}
    Title: ${Context.Title ?? "No title provided"}
    """;

Conditional Logic with Else-If

var template = """
    @if (Context.Score >= 90) {
    Grade: A
    } else if (Context.Score >= 80) {
    Grade: B
    } else if (Context.Score >= 70) {
    Grade: C
    } else {
    Grade: F
    }
    """;

Features

Feature Syntax Example
Variable Interpolation ${Context.xxx} ${Context.Model.Name}
Null Coalescing ${expr ?? "default"} ${Context.Title ?? "Untitled"}
Pipe Helpers ${expr | helper} ${Context.Name | upper}
Conditionals @if (condition) { } @if (Context.IsActive) { ... }
Else-If Chains } else if (condition) { } else if (Context.Role == "admin") { ... }
Negation @if (!condition) { } @if (!Context.IsExpired) { ... }
Iteration @foreach (var x in collection) { } @foreach (var item in Context.Items) { ... }
Comments @* comment *@ @* TODO: Fix this *@
Comparison Operators >, >=, <, <=, ==, != @if (Context.Age >= 21) { ... }

Pipe Helpers

Transform values using the pipe syntax: ${Context.Value | helper} or ${Context.Value | helper:argument}

String Helpers

Helper Example Output
upper ${Context.Name | upper} JOHN
lower ${Context.Name | lower} john
capitalize ${Context.Name | capitalize} John
camelcase ${Context.Name | camelcase} firstName
pascalcase ${Context.Name | pascalcase} FirstName
trim ${Context.Text | trim} hello
truncate:N ${Context.Desc | truncate:20} This is a long te...
replace:old,new ${Context.Path | replace:old,new} Replaces text
padleft:N,char ${Context.Id | padleft:5,0} 00042
padright:N,char ${Context.Name | padright:10,.} John......

Date Helpers

Helper Example Output
format:pattern ${Context.Date | format:yyyy-MM-dd} 2024-06-15
date ${Context.Date | date} 2024-06-15
date:pattern ${Context.Date | date:dd-MMM-yyyy} 15-Jun-2024

Number Helpers

Helper Example Output
number ${Context.Value | number} 1,234
format:N2 ${Context.Price | format:N2} 1,234.57
format:C ${Context.Price | format:C} $1,234.57
format:P0 ${Context.Rate | format:P0} 86%
round:N ${Context.Pi | round:2} 3.14
floor ${Context.Value | floor} 3
ceiling ${Context.Value | ceiling} 4

Collection Helpers

Helper Example Output
count ${Context.Items | count} 5
first ${Context.Items | first} First item
last ${Context.Items | last} Last item
join:separator ${Context.Tags | join:, } a, b, c
reverse ${Context.Word | reverse} olleH

Conditional Helpers

Helper Example Output
default:value ${Context.Name | default:Guest} Guest if null
ifempty:value ${Context.Title | ifempty:N/A} N/A if empty
yesno ${Context.Active | yesno} Yes or No
yesno:yes,no ${Context.Active | yesno:On,Off} On or Off

Chaining Helpers

Helpers can be chained together:

${Context.Name | trim | upper | truncate:20}
${Context.Items | first | upper}
${Context.Date | format:MMMM | upper}

Template Services (Extensibility)

The core library stays tiny by design. Complex features like advanced pluralization or calculations are provided through Template Services—simple functions you register with string keys.

How It Works

Two Ways to Register Services:

1. Simple Functions (Quick & Easy)

// Inline lambda - perfect for simple transformations
context.RegisterService("pluralize", input => input?.ToString()?.Pluralize());

// Use in templates
var template = "We have ${Context.Services('pluralize')('customer')}";
// Output: "We have customers"

2. ITemplateService (IoC/DI)

// Implement the interface
public class HumanizerService : ITemplateService
{
    public string Name => "pluralize";
    
    public object? Transform(object? input)
    {
        return input?.ToString()?.Pluralize();
    }
}

// Register (simple)
context.RegisterService(new HumanizerService());

// Or with dependency injection (ASP.NET Core)
services.AddSingleton<ITemplateService, HumanizerService>();

// In controller
public MyController(IEnumerable<ITemplateService> services)
{
    var context = new ExecutionContext();
    context.RegisterServices(services);  // Registers all IoC services
}

Example: Pluralization with Humanizer

// Install: dotnet add package Humanizer.Core
using Humanizer;

var context = new ExecutionContext();

// Register pluralization service
context.RegisterService("pluralize", input => 
    input?.ToString()?.Pluralize() ?? "");

context.RegisterService("singularize", input => 
    input?.ToString()?.Singularize() ?? "");

// Use in template
var template = "We have 5 ${Context.Services('pluralize')('customer')}.";
var result = engine.Render(template, context);
// Output: "We have 5 customers."

Example: Calculations with NCalc

// Install: dotnet add package NCalc
using NCalc;

// Register calculation service
context.RegisterService("calc", input =>
{
    var expr = new Expression(input?.ToString() ?? "0");
    return expr.Evaluate();
});

// Use in template
var template = "Total: $${Context.Services('calc')('19.99 * 5 * 1.08')}";
var result = engine.Render(template, context);
// Output: "Total: $107.9460"

Service Not Found

If a service isn't registered, you get a clear error message:

var template = "${Context.Services('unknown')('test')}";
// Output: "{unknown not registered}"

Real-World Example: Invoice Generation

// Register services
context.RegisterService("pluralize", input => input?.ToString()?.Pluralize() ?? "");
context.RegisterService("calc", input =>
{
    var expr = new Expression(input?.ToString() ?? "0");
    var result = expr.Evaluate();
    return result is double d ? d.ToString("F2") : result;
});

// Template
var template = """
    Invoice
    -------
    Items: ${Context.Services('calc')('5')} ${Context.Services('pluralize')('widget')}
    Subtotal: $${Context.Services('calc')('19.99 * 5')}
    Tax: $${Context.Services('calc')('19.99 * 5 * 0.08')}
    Total: $${Context.Services('calc')('19.99 * 5 * 1.08')}
    """;

// Output:
// Invoice
// -------
// Items: 5 widgets
// Subtotal: $99.95
// Tax: $8.00
// Total: $107.95

Why This Approach?

Core stays tiny - Zero unnecessary dependencies
Pay for what you use - Only add services you need
Simple - Services are just functions, no complex interfaces
Testable - Easy to mock in unit tests
Flexible - Create any transformation you need
IoC-friendly - Full dependency injection support

Services are simple functions that transform data—nothing more, nothing less.

Advanced: IoC/DI Integration

For production applications with ASP.NET Core or other DI containers, see: 📖 IoC Integration Guide

  • Implement ITemplateService for full DI support
  • Inject services from IoC container
  • Access dependencies (ILogger, IConfiguration, etc.)
  • Production-ready patterns and best practices

Use Cases

  • 📧 Email/Letter Templates - Personalized communications
  • 💻 Code Generation - Generate boilerplate code from models
  • ⚙️ Configuration Files - Environment-specific configs
  • 📄 Documentation - Auto-generate docs from metadata
  • 🧾 Invoices/Reports - Dynamic document generation

Author

👤 wendellmva

🤝 Contributing

Contributions, issues and feature requests are welcome!
Feel free to check issues page.

Show your support

Give a ⭐️ if this project helped you!


This README was generated with ❤️ by readme-md-generator

About

Performs string interpolation on an object's properties.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors