Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 3 additions & 12 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,10 @@ jobs:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable

- name: Resolve Zola next commit
id: zola
run: echo "sha=$(git ls-remote https://github.com/getzola/zola refs/heads/next | cut -f1)" >> "$GITHUB_OUTPUT"
- name: Cache Zola binary
id: cache-zola
uses: actions/cache@v5
- name: Build Zola + upload Pages artifact
uses: getzola/github-pages@066755243e69f508fd1a74739fbf1a65f656c790
with:
path: ~/.cargo/bin/zola
key: zola-next-${{ steps.zola.outputs.sha }}
- name: Install Zola (next branch)
if: steps.cache-zola.outputs.cache-hit != 'true'
# Use next branch temporarily since these docs are built with it
run: cargo install --git https://github.com/getzola/zola --branch next --locked zola
zola_version: v0.23.4

- name: Build docs
run: zola build
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
## 2.3.0 (unreleased)

- Add `State::{escape,autoescaping_enabled,escape_if_needed}`
- Add `StringInput` to use for input/parameters that is safety aware and use it for built-in filters: this
means you should need less `| safe` and that string safety is passed around correctly. Safety is dropped by
filters where the changes depend on the content itself (`truncate` can cut anywhere) and preserved when they specify exactly what changes (`trim`, `replace`).
- Add `escape` filter that is safety aware (it will not escape an already safe string) and uses the current instance
escape function
- Escaping included templates is now done base on _where_ it's included rather than the included template itself:
for example a `.txt` included in a `.html` will now be autoescaped if Tera autoescapes on `.html`.
- Fix double escape of filter/set blocks

## 2.2.0 (2026-08-20)

Expand Down
23 changes: 18 additions & 5 deletions docs/content/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,10 @@ If you want to do that, use components.
While you can `set` values in included templates, those values only exist while rendering
them: the template calling `include` doesn't see them.

For escaping purposes, what's important is where the file is being included.
For example if you have autoescape on for `.html` files, `<script>alert("hello")</script>` in `partial.txt` and
`{% include "partial.txt" %}` in `base.html`, the content of `partial.txt` will be rendered escaped.

### Inheritance

Tera uses the same kind of inheritance as Jinja2 and Django templates:
Expand Down Expand Up @@ -827,11 +831,15 @@ If you are building with something like HTMX you can also re-render a single com
Tera has the following filters built-in:

##### safe
Marks a variable as safe: HTML will not be escaped anymore.
`safe` only works if it is the last filter of the expression:
Marks a variable as safe.

- `{{ content | safe }}` will not be escaped
- `{{ content | replace(from="Robert", to="Bob") | safe }}` will not be escaped
- `{{ content | safe | replace(from="Robert", to="Bob") }}` will be escaped
- `{{ content | safe | replace(from="Robert", to="Bob") }}` will not be escaped either because the `content` is marked as safe and the `replace` filter is safety aware and will escape the `to` parameter if needed
- `{{ content | safe | truncate(length=10) }}` will be escaped because `truncate` is not safety aware

Safety is preserved through safety aware filters when the template author specifies exactly what changes
(`replace`, `trim`, `upper` etc) but dropped by the rest.

##### lower
Converts a string to lowercase.
Expand Down Expand Up @@ -889,6 +897,8 @@ For example, `{{ value | truncate(length=10, end="") }}` will not append anythin
If you have the `unicode` feature enabled, the truncation will be done by graphemes rather than bytes.
Avoid using that filter with user strings if that feature is not enabled.

The output is never a safe string.

##### newlines_to_br
Replaces line breaks (`\n` or `\r\n`) with HTML line breaks (`<br>`).

Expand Down Expand Up @@ -934,6 +944,11 @@ Returns the length of an array, an object, or a string.
##### reverse
Returns a reversed string or array.

##### escape

Escape a string input using the currently defined escape function.
This is aware of the current state of the input so an already safe string will not be escaped.

##### escape_html
Escapes a string's HTML. Specifically, it makes these replacements:

Expand Down Expand Up @@ -1238,6 +1253,4 @@ The template rendering will error with the given message when encountered.

There is only one string argument: `message` which is the message to display as the error



{% endraw %}
13 changes: 13 additions & 0 deletions tera-contrib/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
//! Additional features for Tera that require 3rd party dependencies
//! These are in a separate package so the crate version can be changed independently
//! of the tera crate.
//!
//! To use them, call the Tera.{register_filter,register_function,register_test} functions:
//!
//! ```ignore
//! use tera::Tera;
//!
//! let mut tera = Tera::default();
//! tera.register_filter("b64_encode", tera_contrib::base64::b64_encode);
//! ```
//!
#[cfg(feature = "base64")]
pub mod base64;
#[cfg(feature = "date")]
Expand Down
20 changes: 14 additions & 6 deletions tera-contrib/src/regex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::sync::{LazyLock, RwLock};

use regex::Regex;
use tera::{Filter, Kwargs, State, TeraResult, Test};
use tera::{Filter, Kwargs, State, StringInput, TeraResult, Test, Value};

static STRIPTAGS_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(<!--.*?-->|<[^>]*>)").unwrap());
Expand Down Expand Up @@ -30,8 +30,10 @@ pub fn striptags(val: &str, _: Kwargs, _: &State) -> String {
/// ```text
/// {{ value | spaceless }}
/// ```
pub fn spaceless(val: &str, _: Kwargs, _: &State) -> String {
SPACELESS_RE.replace_all(val, "><").into_owned()
pub fn spaceless(val: StringInput, _: Kwargs, _: &State) -> Value {
// We're removing spaces between HTML tags so if it was safe before, it should still be safe
// Of course if it's used outside of HTML content it might be wrong.
val.inherit_safety(SPACELESS_RE.replace_all(val.as_str(), "><").into_owned())
}

fn get_or_create_regex(cache: &RwLock<HashMap<String, Regex>>, pattern: &str) -> TeraResult<Regex> {
Expand Down Expand Up @@ -94,8 +96,8 @@ impl Filter<&str, TeraResult<String>> for RegexReplace {
mod tests {
use super::*;
use std::sync::Arc;
use tera::Context;
use tera::value::Map;
use tera::{ArgFromValue, Context, StringInput};

#[test]
fn test_striptags() {
Expand Down Expand Up @@ -127,6 +129,7 @@ mod tests {
r#"<strong>foo</strong><a href="http://example.com">bar</a>"#,
"foobar",
),
("a &amp; b", "a &amp; b"),
];
for (input, expected) in tests {
let ctx = Context::new();
Expand All @@ -149,8 +152,13 @@ mod tests {
for (input, expected) in tests {
let ctx = Context::new();
let state = State::new(&ctx);
let res = spaceless(input, Kwargs::default(), &state);
assert_eq!(expected, res);
let val = Value::from(input);
let res = spaceless(
StringInput::from_value(&val).unwrap(),
Kwargs::default(),
&state,
);
assert_eq!(expected, res.as_str().unwrap());
}
}

Expand Down
73 changes: 71 additions & 2 deletions tera/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ use serde::Deserialize;
use std::borrow::Cow;
use std::sync::Arc;

use crate::Value;
use crate::errors::{Error, TeraResult};
use crate::value::number::Number;
use crate::value::{Key, Map, ValueInner};
use crate::{State, Value};

mod private {
use super::{Map, Number, Value};
use super::{Map, Number, StringInput, Value};
use std::borrow::Cow;

pub trait Sealed {}
Expand Down Expand Up @@ -38,6 +38,7 @@ mod private {
impl Sealed for Map {}
impl Sealed for &Map {}
impl<T: Sealed> Sealed for Vec<T> {}
impl Sealed for StringInput<'_> {}
}

/// Converts a template Value into a type that can be used in Rust code
Expand Down Expand Up @@ -350,6 +351,74 @@ impl<const N: usize> From<[(&'static str, Value); N]> for Kwargs {
}
}

/// A Value::String with some helpers around it for safety
/// The main usage is to use it as the value or string parameters for filters operating
/// on strings (for example a filter block)
///
/// # Examples
///
/// ```
/// use tera::{Tera, Kwargs, State, StringInput};
/// let mut tera = Tera::default();
/// tera.register_filter("is_safe", |x: StringInput, _: Kwargs, _: &State| x.is_safe());
/// tera.register_filter("uppercase", |x: StringInput, _: Kwargs, _: &State| x.inherit_safety(x.as_str().to_uppercase()));
/// ```
#[derive(Debug)]
pub struct StringInput<'a> {
pub(crate) inner: &'a Value,
}

impl<'a> StringInput<'a> {
/// Returns this StringInput as a Value, with the correct safety flag
pub fn into_value(self) -> Value {
self.inner.clone()
}

/// Returns the actual string
pub fn as_str(&self) -> &'a str {
self.inner.as_str().unwrap()
}

/// `true` if the original value was marked safe
pub fn is_safe(&self) -> bool {
self.inner.is_safe()
}

/// Returns a new Value for the given output, inheriting the StringInput safety
/// This should only be used when the new output is taken directly from the StringInput
/// and there's no deletion/decoding etc that could make the output unsafe.
/// If it's misused, it could mark some unsafe strings as safe.
pub fn inherit_safety(&self, output: String) -> Value {
if self.is_safe() {
Value::safe_string(&output)
} else {
Value::from(output)
}
}

/// Render the current string as if it was used in `{{ }}` context, taking
/// into account its own safety flag as well as whether autoescaping is currently enabled.
pub fn rendered(&self, state: &State) -> TeraResult<Cow<'_, str>> {
if self.is_safe() || !state.autoescaping_enabled() {
Ok(Cow::Borrowed(self.as_str()))
} else {
Ok(Cow::Owned(state.escape(self.as_str())?))
}
}
}

impl<'k> ArgFromValue<'k> for StringInput<'_> {
type Output = StringInput<'k>;

fn from_value(value: &'k Value) -> TeraResult<Self::Output> {
if value.is_string() {
Ok(StringInput { inner: value })
} else {
Err(Error::invalid_arg_type("string", value.name()))
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading