md-tmpl 0.9.3

Lightweight template engine for .tmpl.md prompt files with typed frontmatter
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# md-tmpl

Strongly-typed prompt templates for LLMs — markdown files with YAML frontmatter,
validated at **build time** via proc macros, with a full runtime API for dynamic loading.

```rust
use md_tmpl::include_template;

// Parses and validates the template at build time, generates typed structs + enums.
include_template!("prompts/task_report.tmpl.md");

// Generated types:
//   task_report::Params                  — typed struct
//   task_report::ParamsPriority          — enum(Critical, High, Medium, Low)
//   task_report::ParamsTasksItem         — struct { name, urgency }
//   task_report::ParamsTasksItemUrgency  — enum(Critical, High, Medium, Low)

let params = task_report::Params::builder()
    .title("Deploy v2.0")
    .priority(task_report::ParamsPriority::Critical)
    .tasks([
        task_report::ParamsTasksItem::builder()
            .name("run migrations")
            .urgency(task_report::ParamsTasksItemUrgency::High)
            .build(),
        task_report::ParamsTasksItem::builder()
            .name("update load balancer")
            .urgency(task_report::ParamsTasksItemUrgency::Medium)
            .build(),
    ])
    .build();

let output = params.render().unwrap();
assert!(output.contains("# Task Report: Deploy v2.0"));
assert!(output.contains("Priority: Critical"));
assert!(output.contains("run migrations (High)"));
```

The template behind it — a plain `.tmpl.md` markdown file:

```markdown
---
name: task_report
description: A task report template with types
types:
  - Priority = enum(Critical, High, Medium, Low)

params:
  - title = str
  - priority = Priority
  - tasks = list(name = str, urgency = Priority)
---

# Task Report: {{ title }}

Priority: {{ kind(priority) }}

> {% for task in tasks %}

- {{ task.name }} ({{ kind(task.urgency) }})

> {% /for %}
```

Rename a variant, add a field, remove a param — the compiler catches it
immediately. No runtime surprises.

## Why?

- **Build-time validation** — proc macros parse and validate syntax, types, and variable references at `cargo build`. Typos, missing fields, and type mismatches are build errors. Templates can also be loaded and validated at runtime.
- **Markdown-native** — prompts live in `.tmpl.md` files, readable in any editor or on GitHub. Compound types use `()` (never `<>`), control-flow tags use `> {% %}` blockquote prefixes.
- **Agent-safe** — when an LLM edits prompts, the compiler catches drift immediately. `validate_template()` enables hot-reload with contract enforcement.

## Installation

Available on crates.io: <https://crates.io/crates/md-tmpl> (and <https://crates.io/crates/md-tmpl-macros>)

```bash
cargo add md-tmpl
# macros are included by default — no extra dependency needed!
# (md-tmpl re-exports include_template! and template! macros)
```

**MSRV:** 1.85 (Rust 2024 edition) · **`no_std`** compatible (disable default `std` feature)

## Template Syntax & Features

| Feature                    | Syntax / Example                                                                         |
| -------------------------- | ---------------------------------------------------------------------------------------- |
| **Typed parameters**       | `str`, `int`, `float`, `bool`, `list(…)`, `struct(…)`, `enum(…)`, `option(…)`, `tmpl(…)` |
| **Type aliases**           | `types:` block defines reusable named types (`Priority = enum(High, Low)`)               |
| **Cross-template imports** | `imports:` pulls types via dotted paths (`stem.TypeName`)                                |
| **Constants**              | `consts:` block for file-scoped immutable values                                         |
| **Environment variables**  | `env:` block for compile-time injection from the build environment                       |
| **String interpolation**   | `{{ expr }}` inside all quoted strings — conditions, includes, panic messages            |
| **For loops & else**       | `> {% for task in tasks %} … > {% else %} empty > {% /for %}`                            |
| **Conditionals**           | `> {% if count > 0 %} … > {% elif active %} … > {% else %} … > {% /if %}`                |
| **Enum dispatch**          | `> {% match status %} > {% case Approved %} … > {% case Rejected %} … > {% /match %}`    |
| **Includes as links**      | `> {% include [widget](widget.tmpl.md) with title = "Hello" %}`                          |
| **Inline templates**       | `> {% tmpl header %} … > {% /tmpl %}` (call with `{% include header %}`)                 |
| **Built-in functions**     | `idx(b)`, `len(x)`, `kind(x)`, `kinds(Type)`, `has(x)`                                   |
| **Filters**                | `upper`, `lower`, `trim`, `fixed(N)`, `join(sep)`, `limit(N)`, `add(N)`, `sub(N)`        |

## Build-Time Typed Structs

### `include_template!`

Reads a `.tmpl.md` file at build time, validates it, and generates a typed
module:

```rust
use md_tmpl::include_template;

// Generates: pub mod simple_greeting { pub struct Params { pub name: String } }
include_template!("prompts/simple_greeting.tmpl.md");

let output = simple_greeting::Params { name: "world".into() }.render().unwrap();
assert_eq!(output, "\nHello world!\n");
```

### `template!`

Inline template strings — same validation, no file needed:

```rust
md_tmpl::template!(r#"
---
params:
  - name = str
---
Hello {{ name }}!
"# => greeting);

let output = greeting::Params { name: "World".into() }
    .render()
    .unwrap();
assert_eq!(output, "Hello World!\n");
```

## `TypedBuilder` Integration

Every generated param struct derives `TypedBuilder` for ergonomic,
compile-time-checked construction. This is **always available** — no feature
flag to enable and no `typed-builder` dependency to add yourself (the derive is
re-exported by `md-tmpl`):

```rust
# md_tmpl::include_template!("prompts/greeting.tmpl.md");
let params = greeting::Params::builder()
    .name("Alice")       // setter(into): accepts &str or String
    .count(42)
    .build();            // `items` defaults to vec![]

let output = params.render().unwrap();
```

| Field type                     | Builder behaviour                                                     |
| ------------------------------ | --------------------------------------------------------------------- |
| `String`                       | `setter(into)` — accepts `&str`, `String`, or anything `Into<String>` |
| `Vec<…>`                       | `default` — omit the field to get an empty `Vec`                      |
| Scalars (`i64`, `f64`, `bool`) | Required                                                              |

Sub-structs also derive `TypedBuilder`:

```rust
# md_tmpl::include_template!("prompts/greeting.tmpl.md");
let item = greeting::ParamsItemsItem::builder()
    .label("write docs")
    .build();

let params = greeting::Params::builder()
    .name("Alice")
    .count(1)
    .items(vec![item])
    .build();
```

## serde Integration

Render directly from any `Serialize` struct:

```bash
cargo add md-tmpl --features serde
```

```rust
use md_tmpl::Template;
use serde::Serialize;

#[derive(Serialize)]
struct ReviewParams {
    file_path: String,
    severity: String,
    findings: Vec<Finding>,
}

#[derive(Serialize)]
struct Finding { line: i64, message: String }

let tmpl = Template::from_source("\
---
params:
  - file_path = str
  - severity = str
  - findings = list(line = int, message = str)
---

# Code Review: {{ file_path }}

Severity: {{ severity }}

> {% for finding in findings %}

- Line {{ finding.line }}: {{ finding.message }}

> {% /for %}"
).unwrap();

let output = tmpl.render(&ReviewParams {
    file_path: "main.rs".into(),
    severity: "high".into(),
    findings: vec![
        Finding { line: 42, message: "unused variable".into() },
    ],
}).unwrap();
```

## Binary Deserialization (CBOR & `FlexBuffers`)

Build a `Context` straight from a binary buffer — useful for language bindings
and zero-copy pipelines. Each format lives behind its own feature, so you only
compile the codecs you actually use (both are enabled by default):

- `cbor``Context::from_cbor` (works in `no_std`, via `ciborium`)
- `flexbuffers``Context::from_flexbuffers` and `Value::from_flexbuffers`
  (requires `std`)

Opt out of the defaults to trim dependencies down to exactly what you need:

```bash
# serde + CBOR only, no FlexBuffers (and its transitive deps)
cargo add md-tmpl --no-default-features --features std,serde,cbor
```

## Runtime API

For dynamic or scripting use cases, parse templates at runtime.

### `ctx!` Macro

Ergonomic context construction with nested structs and lists:

```rust
use md_tmpl::{ctx, Template};

let tmpl = Template::from_source("
---
params:
  - tasks = list(title = str, priority = str)
---

> {% for task in tasks %}

- **{{ task.title }}** ({{ task.priority }})

> {% /for %}"
).unwrap();

let output = tmpl.render_ctx(&ctx! {
    tasks: [
        { title: "Write documentation", priority: "High" },
        { title: "Add unit tests",      priority: "Medium" },
    ]
}).unwrap();

assert_eq!(output, "- **Write documentation** (High)\n- **Add unit tests** (Medium)\n");
```

### Runtime Loading

```rust
use md_tmpl::load_template;

let tmpl = load_template(std::path::Path::new("prompts"), "simple_greeting").unwrap();

let mut ctx = md_tmpl::Context::new();
ctx.set("name", "world");
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("Hello world!"));
```

### Environment Variables

Inject values at compile time from the build environment:

```rust
use md_tmpl::{ctx, Template, CompileOptions, Value};

let (tmpl, _fm) = Template::compile("\
---
params:
  - name = str

env:
  - MODEL = str
  - MAX_TOKENS = int := 4096
---
Hello {{ name }}! Using {{ MODEL }} (max {{ MAX_TOKENS }} tokens).",
    CompileOptions::default().env(&[("MODEL", Value::Str("gemini-2.0-flash".into()))]),
).unwrap();

let output = tmpl.render_ctx(&ctx! { name: "Alice" }).unwrap();
assert!(output.contains("gemini-2.0-flash"));
assert!(output.contains("4096")); // default used
```

## Hot-Reload

Load templates from disk at runtime while keeping type safety —
iterate on prompt wording without recompiling:

```rust
# md_tmpl::include_template!("prompts/greeting.tmpl.md");
let tmpl = md_tmpl::Template::from_file(
    std::path::Path::new("prompts/greeting.tmpl.md")
).unwrap();
greeting::Params::validate_template(&tmpl).unwrap();

let output = greeting::Params {
    name: "Bob".into(),
    count: 1,
    items: vec![],
}.render_reloaded(&tmpl).unwrap();
```

## Caching

`TemplateCache` hashes file contents — unchanged files return cached
compilations. `render_cached()` extends this to included templates:

```rust
use md_tmpl::TemplateCache;

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("greeting.tmpl.md");
std::fs::write(&path, "\
---
params:
  - name = str
---
Hello {{ name }}!"
).unwrap();

let cache = TemplateCache::new();
let tmpl = cache.load(&path).unwrap();

let mut ctx = md_tmpl::Context::new();
ctx.set("name", "world");
let output = tmpl.render_ctx_cached(&ctx, &cache).unwrap();
assert_eq!(output, "Hello world!");
```

## Defaults & Extra Params

### `render_allowing_extra()`

Extra context keys not declared in frontmatter are silently ignored:

```rust
use md_tmpl::{ctx, Template};

let tmpl = Template::from_source("\
---
params:
  - name = str
---
Hello {{ name }}!"
).unwrap();
let ctx = ctx! { name: "world", extra_key: "ignored" };
assert_eq!(tmpl.render_ctx_allowing_extra(&ctx).unwrap(), "Hello world!");
```

### `defaults_context()`

Returns a `Context` pre-filled with default values:

```rust
use md_tmpl::Template;

let tmpl = Template::from_source("\
---
params:
  - name = str
  - count = int := 5
---
{{ name }} ({{ count }})"
).unwrap();
let mut ctx = tmpl.defaults_context();
ctx.set("name", "Alice"); // count already has default 5
assert_eq!(tmpl.render_ctx(&ctx).unwrap(), "Alice (5)");
```

## Performance

### vs Competitors

Criterion benchmarks, render only (pre-parsed template + data → output).
([source](../../benchmarks/benches/comparison.rs))

| Scenario        |        md-tmpl |     Tera | `MiniJinja` | Handlebars |
| --------------- | -------------: | -------: | ----------: | ---------: |
| **simple**      |  **164 ns** 🏆 |   214 ns |      548 ns |     715 ns |
| **loop**        |  **499 ns** 🏆 |   637 ns |     1.90 µs |    3.32 µs |
| **conditional** |  **218 ns** 🏆 |   369 ns |      598 ns |    1.39 µs |
| **hero**        | **2.13 µs** 🏆 |  2.18 µs |     7.58 µs |   24.01 µs |
| **mega**        | **8.53 µs** 🏆 | 10.63 µs |    28.46 µs |   90.35 µs |

_Intel Xeon @ 2.60 GHz, 3 runs × 100 Criterion samples._

```bash
just bench-rust          # run Criterion benchmarks
just bench-update-rust   # run + update this table
```

## Full Reference

See **[SPEC.md](../../SPEC.md)** for the complete syntax — control-flow
tags, filters, built-in functions, whitespace control, and error
diagnostics.

## License

Apache-2.0 OR MIT