Templating

standout-render uses a two-pass templating system that combines a template engine for logic and data binding with a custom BBCode-like syntax for styling. This separation keeps templates readable while providing full control over both content and presentation.

The default engine is MiniJinja (Jinja2-compatible), but alternative engines are available. See Template Engines for options including a lightweight SimpleEngine for reduced binary size.


Two-Pass Rendering Pipeline

Templates are processed in two distinct passes:

Template + Data → [Pass 1: MiniJinja] → Text with style tags → [Pass 2: BBParser] → Final output

Pass 1 - MiniJinja: Standard template processing. Variables are substituted, control flow executes, filters apply.

Pass 2 - BBParser: Style tag processing. Bracket-notation tags are converted to ANSI escape codes (or stripped, depending on output mode).

Pipeline Example

Template:     [title]{{ name }}[/title] has {{ count }} items
Data:         { name: "Report", count: 42 }

After Pass 1: [title]Report[/title] has 42 items
After Pass 2: \x1b[1;36mReport\x1b[0m has 42 items  (or plain: "Report has 42 items")

This separation means:

  • Template logic (loops, conditionals) is handled by MiniJinja—a mature, well-documented engine
  • Style application is a simple, predictable transformation
  • You can debug each pass independently

MiniJinja Basics

MiniJinja implements Jinja2 syntax, a widely-used templating language. Here's a quick overview:

Variables

{{ variable }}
{{ object.field }}
{{ list[0] }}

Control Flow

{% if condition %}
  Show this
{% elif other_condition %}
  Show that
{% else %}
  Default
{% endif %}

{% for item in items %}
  {{ loop.index }}. {{ item.name }}
{% endfor %}

Filters

{{ name | upper }}
{{ list | length }}
{{ value | default("N/A") }}
{{ text | truncate(20) }}

Comments

{# This is a comment and won't appear in output #}

For comprehensive MiniJinja documentation, see the MiniJinja documentation.

Booleans and None

Standout renders these the Rust way — true, false, none — not the Jinja2 way MiniJinja itself uses (True, False, None). This holds for interpolation, loop and set bindings, | string, | join, sequence and map literals, standout's own filters, and table cells:

{{ flag }}                {# true #}
{{ missing }}             {# none #}
{{ flags }}               {# [true, false, none] #}
{{ flags | join(", ") }}  {# true, false, none #}

Two exceptions:

  • The ~ concatenation operator formats inside MiniJinja's evaluator, which exposes no hook: {{ "x" ~ flag }} yields xTrue. Write {{ "x" }}{{ flag }} or {{ "x" ~ flag | string }}.
  • Structured output (JSON, YAML, CSV, NDJSON) skips templates entirely, so those modes follow their format's own rules: JSON, YAML and CSV serialize your data as the document, and NDJSON writes it inside a {"type":"result","data":…} line.

If you build a minijinja::Environment yourself, use standout_render::template::new_environment() — or call register_filters on your own environment, which installs the same spelling.


The Trailing-Newline Contract

Two things happen to the newline at the end of a template, and together they are observable in the bytes a script reads, so they are stated here rather than discovered by probing.

The engine consumes exactly one final newline. This is Jinja's rule and MiniJinja keeps it. A template file ending in a single \n renders with no trailing newline at all; a file ending in two renders with one.

Template sourceRendered string
{{ name }}x
{{ name }}\nx
{{ name }}\n\nx\n
{{ name }}\n\n\nx\n\n

The process edge appends exactly one newline. App::run writes a handled command's text with writeln!, so what reaches stdout is the rendered string plus one \n — whatever the template ended with.

The practical consequence: a template that ends with one newline and a template that ends with none produce identical bytes. To end a page with a blank line, the template needs two trailing newlines. Every editor that adds a final newline on save is therefore invisible here, which is the reason the rule is worth stating.


Style Tags

Style tags use BBCode-like bracket notation to apply named styles from your theme:

[style-name]content to style[/style-name]

Basic Usage

[title]Report Summary[/title]
[error]Something went wrong![/error]
[muted]Last updated: {{ timestamp }}[/muted]

Nesting

Tags can nest properly:

[outer][inner]nested content[/inner][/outer]

Spanning Lines

Tags can span multiple lines:

[panel]
This is a multi-line
block of styled content
[/panel]

With Template Logic

Style tags and MiniJinja work together seamlessly:

[title]{% if custom_title %}{{ custom_title }}{% else %}Default Title{% endif %}[/title]

{% for task in tasks %}
[{{ task.status }}]{{ task.title }}[/{{ task.status }}]
{% endfor %}

The second example shows dynamic style names—the style applied depends on the value of task.status.

Literal brackets

Pass 2 reads [ as the start of a style tag. To put a literal [ in the output, escape it as \[; escape a literal ] as \]:

Ready \[y/n\]
Range \[0, 100\]

renders as Ready [y/n] and Range [0, 100]. The escape works in every output mode, and an escaped bracket is never treated as a tag, so it raises no unresolved-tag warning. A [ that does not begin a valid tag name (for example [0, 100]) is already left literal, so escaping is only needed when the bracketed text would otherwise parse as a tag.

This is distinct from MiniJinja's {{/}} brace escape, which belongs to Pass 1 (see Template Engines) and does not affect the Pass 2 bracket syntax.


Style Modes

Pass 2 (BBParser) processes style tags differently according to the StyleMode the run resolves to:

Style modeBehaviorUse Case
AnsiReplace tags with ANSI escape codesRich terminal output
PlainStrip tags completelyPlain text, pipes, files
DebugKeep tags as literal textDebugging, testing

Ansi and Plain are the two ways Representation::Human can render, chosen by the run's ColorPolicy against the destination's color capability. Debug is what Representation::TermDebug renders as.

Style Modes Example

Template: [title]Hello[/title]

  • Ansi: \x1b[1;36mHello\x1b[0m (rendered as cyan bold)
  • Plain: Hello
  • Debug: [title]Hello[/title]

The "strip tags completely" behavior applies to tags the active theme defines. An unknown tag degrades differently, and an unbalanced unknown tag survives verbatim even under a plain style mode — see Unknown Style Tags.

Setting the Representation and the Color Policy

#![allow(unused)]
fn main() {
use standout_render::{render_with_output, ColorPolicy, Representation};

// Rich terminal
let output = render_with_output(template, &data, &theme, Representation::Human, ColorPolicy::Always)?;

// Plain text
let output = render_with_output(template, &data, &theme, Representation::Human, ColorPolicy::Never)?;

// Debug (tags visible)
let output = render_with_output(template, &data, &theme, Representation::TermDebug, ColorPolicy::Auto)?;

// Decide from the destination's color capability
let output = render_with_output(template, &data, &theme, Representation::Human, ColorPolicy::Auto)?;
}

The style decision

Representation::Human renders with or without escape sequences according to the run's ColorPolicy and the destination's color capability; the rule is in Output Modes.


Built-in Filters

Beyond MiniJinja's standard filters, standout-render provides formatting filters:

Column Formatting

{{ value | col(10) }}                              {# pad/truncate to 10 chars #}
{{ value | col(20, align="right") }}               {# right-align in 20 chars #}
{{ value | col(15, truncate="middle") }}           {# truncate in middle #}
{{ value | col(15, truncate="start", ellipsis="...") }}

Padding

{{ "42" | pad_left(8) }}      {# "      42" #}
{{ "hi" | pad_right(8) }}     {# "hi      " #}
{{ "hi" | pad_center(8) }}    {# "   hi   " #}

Truncation

{{ long_text | truncate_at(20) }}                   {# "Very long text th..." #}
{{ path | truncate_at(30, "middle", "...") }}      {# "/home/.../file.txt" #}
{{ text | truncate_at(20, "start") }}              {# "...end of the text" #}

Display Width

{% if value | display_width > 20 %}
  {{ value | truncate_at(20) }}
{% else %}
  {{ value }}
{% endif %}

Returns visual width (handles Unicode—CJK characters count as 2).

Style Application

{{ value | style_as("error") }}                    {# wraps in [error]...[/error] #}
{{ task.status | style_as(task.status) }}         {# dynamic: [pending]pending[/pending] #}

Template Registry

When using the Renderer struct, templates are resolved by name through a registry:

#![allow(unused)]
fn main() {
use standout_render::Renderer;

let mut renderer = Renderer::new(theme)?;

// Add inline template
renderer.add_template("greeting", "Hello, [name]{{ name }}[/name]!")?;

// Add directory of templates
renderer.add_template_dir("./templates")?;

// Render by name
let output = renderer.render("greeting", &data)?;
}

Resolution Priority

  1. Inline templates (added via add_template())
  2. Directory templates (from add_template_dir())

File Extensions

Supported extensions (in priority order): .jinja, .jinja2, .j2, .stpl, .txt

When you request "report", the registry checks:

  • Inline template named "report"
  • report.jinja in registered directories
  • report.jinja2, report.j2, report.stpl, report.txt (lower priority)

The .stpl extension is for SimpleEngine templates. See Template Engines for details.

Template Names

Template names are derived from relative paths:

templates/
├── greeting.jinja       → "greeting"
├── reports/
│   └── summary.jinja    → "reports/summary"
└── errors/
    └── 404.jinja        → "errors/404"

Including Templates

Templates can include other templates using MiniJinja's include syntax:

{# main.jinja #}
[title]{{ title }}[/title]

{% include "partials/header.jinja" %}

{% for item in items %}
  {% include "partials/item.jinja" %}
{% endfor %}

{% include "partials/footer.jinja" %}

This enables reusable components across your application.


Context Variables

Beyond your data, you can inject additional context into templates:

#![allow(unused)]
fn main() {
use standout_render::{render_with_vars, ColorPolicy, Representation};
use std::collections::HashMap;

let mut vars = HashMap::new();
vars.insert("version", "1.0.0");
vars.insert("app_name", "MyApp");

let output = render_with_vars(
    "{{ app_name }} v{{ version }}: {{ message }}",
    &data,
    &theme,
    Representation::Human,
    ColorPolicy::Always,
    vars,
)?;
}

When handler data and context variables have the same key, handler data wins. Context is supplementary.


Structured Output

For machine-readable output (JSON, YAML, CSV, NDJSON), templates are bypassed entirely:

#![allow(unused)]
fn main() {
use standout_render::{render_auto, ColorPolicy, Representation};

// The template is used for the human representation
// Data is serialized directly for Json/Yaml/Csv; Ndjson wraps it in a result entry
let output = render_auto(template, &data, &theme, Representation::Json, ColorPolicy::Auto)?;
}
ModeBehavior
TermRender template, apply styles
TextRender template, strip styles
TermDebugRender template, keep style tags
Jsonserde_json::to_string_pretty(data)
Yamlserde_yaml::to_string(data)
CsvOne row per flat record; a nested value is a render error
NdjsonOne compact line, {"type":"result","data":…}

This means your serializable data types automatically support structured output without additional code.


Validation

Check templates for unknown style tags before deploying:

#![allow(unused)]
fn main() {
use standout_render::validate_template;

validate_template(template, &sample_data, &theme)?;
}

The error lists every unknown or unbalanced tag.


API Reference

Render Functions

#![allow(unused)]
fn main() {
use standout_render::{
    ColorPolicy, Representation,
    render,                  // Basic: template + data + theme
    render_with_output,      // With explicit output mode
    render_with_mode,        // With representation + color policy + color mode
    render_with_vars,        // With extra context variables
    render_auto,             // Auto-dispatch template vs serialize
    render_auto_with_context,
};

// Basic
let output = render(template, &data, &theme)?;

// With a representation and a color policy
let output = render_with_output(template, &data, &theme, Representation::Human, ColorPolicy::Always)?;

// With color mode override (for testing)
let output = render_with_mode(
    template,
    &data,
    &theme,
    Representation::Human,
    ColorPolicy::Always,
    ColorMode::Dark,
)?;

// Auto (template for the human representation, serialize for structured)
let output = render_auto(template, &data, &theme, Representation::Json, ColorPolicy::Auto)?;
}

Renderer Struct

#![allow(unused)]
fn main() {
use standout_render::{ColorPolicy, Renderer};

let mut renderer = Renderer::new(theme)?;
renderer.add_template("name", "content")?;
renderer.add_template_dir("./templates")?;

let output = renderer.render("name", &data)?;
renderer.set_color_policy(ColorPolicy::Never);
let output = renderer.render("name", &data)?;
}