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, XML, CSV) skips templates entirely and serializes your data directly, so those modes follow their format's own rules.

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.

standout-render/tests/trailing_newline.rs pins the engine half; final_emission_routes_success_and_diagnostics_to_distinct_streams in standout/src/cli/builder/execution.rs pins the process half.


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.


Processing Modes

Pass 2 (BBParser) processes style tags differently based on the output mode:

ModeBehaviorUse Case
TermReplace tags with ANSI escape codesRich terminal output
TextStrip tags completelyPlain text, pipes, files
TermDebugKeep tags as literal textDebugging, testing

Processing Modes Example

Template: [title]Hello[/title]

  • Term: \x1b[1;36mHello\x1b[0m (rendered as cyan bold)
  • Text: Hello
  • TermDebug: [title]Hello[/title]

Setting the Mode

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

// Rich terminal
let output = render_with_output(template, &data, &theme, OutputMode::Term)?;

// Plain text
let output = render_with_output(template, &data, &theme, OutputMode::Text)?;

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

// Auto-detect based on TTY
let output = render_with_output(template, &data, &theme, OutputMode::Auto)?;
}

Auto Mode

OutputMode::Auto detects the appropriate mode:

  • If stdout is a TTY with color support → Term
  • If stdout is a pipe or redirect → Text

For standout framework users: The framework's --output CLI flag automatically sets the output mode. See standout documentation for details.


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, OutputMode};
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,
    OutputMode::Term,
    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), templates are bypassed entirely:

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

// Template is used for Term/Text modes
// Data is serialized directly for Json/Yaml/Csv
let output = render_auto(template, &data, &theme, OutputMode::Json)?;
}
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)
CsvFlatten and format as CSV

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;

let errors = validate_template(template, &sample_data, &theme);
if !errors.is_empty() {
    for error in &errors {
        eprintln!("Unknown style tag: [{}]", error.tag_name);
    }
}
}

Validation catches:

  • Misspelled style names
  • References to undefined styles
  • Mismatched opening/closing tags

API Reference

Render Functions

#![allow(unused)]
fn main() {
use standout_render::{
    render,                  // Basic: template + data + theme
    render_with_output,      // With explicit output mode
    render_with_mode,        // With output mode + 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 output mode
let output = render_with_output(template, &data, &theme, OutputMode::Term)?;

// With color mode override (for testing)
let output = render_with_mode(template, &data, &theme, OutputMode::Term, ColorMode::Dark)?;

// Auto (template for text modes, serialize for structured)
let output = render_auto(template, &data, &theme, OutputMode::Json)?;
}

Renderer Struct

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

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

let output = renderer.render("name", &data)?;
let output = renderer.render_with_mode("name", &data, OutputMode::Text)?;
}