diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6a5a2958..621a94e11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## 2.2.0 (unreleased)
- Do not reserialize existing Value when serializing to a Value
+- Allow trimming start/end of component definition with metadata `{"trim": true}`
## 2.1.1 (2026-08-11)
diff --git a/tera/src/parsing/ast.rs b/tera/src/parsing/ast.rs
index 9d6500e05..041bbf4f1 100644
--- a/tera/src/parsing/ast.rs
+++ b/tera/src/parsing/ast.rs
@@ -755,6 +755,43 @@ impl ComponentDefinition {
self.kwargs.keys().map(|k| k.as_str()).collect()
}
+ /// Trim body unless the `trim` metadata field is set to `false` or unset
+ pub fn maybe_trim_body(&mut self) {
+ let needs_trimming = self
+ .metadata
+ .get("trim")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ if !needs_trimming {
+ return;
+ }
+
+ // We might have multiple Node::Content at start/end if there were comment nodes that got dropped
+ while let Some(Node::Content(c)) = self.body.first_mut() {
+ let t = c.trim_start();
+ if t.is_empty() {
+ self.body.remove(0);
+ } else {
+ if t.len() != c.len() {
+ *c = t.to_string();
+ }
+ break;
+ }
+ }
+
+ while let Some(Node::Content(c)) = self.body.last_mut() {
+ let t = c.trim_end();
+ if t.is_empty() {
+ self.body.pop();
+ } else {
+ if t.len() != c.len() {
+ *c = t.to_string();
+ }
+ break;
+ }
+ }
+ }
+
/// Builds a validated context from provided kwargs, checking types and applying defaults.
/// If rest_param_name is defined, unknown kwargs are collected into it.
/// Otherwise, unknown kwargs will error.
diff --git a/tera/src/parsing/parser.rs b/tera/src/parsing/parser.rs
index 6970e178d..4533e892d 100644
--- a/tera/src/parsing/parser.rs
+++ b/tera/src/parsing/parser.rs
@@ -1450,6 +1450,8 @@ impl<'a> Parser<'a> {
self.body_contexts.pop();
component_def.body = body;
+ component_def.maybe_trim_body();
+
Ok(component_def)
}
diff --git a/tera/src/snapshot_tests/parser.rs b/tera/src/snapshot_tests/parser.rs
index 38c9d90a8..ca5358dab 100644
--- a/tera/src/snapshot_tests/parser.rs
+++ b/tera/src/snapshot_tests/parser.rs
@@ -56,6 +56,7 @@ fn parser_errors() {
fn parser_components_definition_success() {
insta::glob!("parser_inputs/success/components/def/*.txt", |path| {
let contents = std::fs::read_to_string(path).unwrap();
+ let contents = normalize_line_endings(&contents);
let components = &Parser::new("", &contents, Delimiters::default())
.parse()
.unwrap()
@@ -68,6 +69,7 @@ fn parser_components_definition_success() {
fn parser_components_render_success() {
insta::glob!("parser_inputs/success/components/*.txt", |path| {
let contents = std::fs::read_to_string(path).unwrap();
+ let contents = normalize_line_endings(&contents);
println!("{path:?}");
let nodes = &Parser::new("", &contents, Delimiters::default())
.parse()
diff --git a/tera/src/snapshot_tests/parser_inputs/success/components/def/trim_default.txt b/tera/src/snapshot_tests/parser_inputs/success/components/def/trim_default.txt
new file mode 100644
index 000000000..4ee1f2a92
--- /dev/null
+++ b/tera/src/snapshot_tests/parser_inputs/success/components/def/trim_default.txt
@@ -0,0 +1,3 @@
+{% component greet(label) %}
+{{label}}
+{% endcomponent %}
diff --git a/tera/src/snapshot_tests/parser_inputs/success/components/def/trim_default_with_comment.txt b/tera/src/snapshot_tests/parser_inputs/success/components/def/trim_default_with_comment.txt
new file mode 100644
index 000000000..933b7302b
--- /dev/null
+++ b/tera/src/snapshot_tests/parser_inputs/success/components/def/trim_default_with_comment.txt
@@ -0,0 +1,4 @@
+{% component greet(label) %}
+{# this has ws after #}
+{{label}}
+{% endcomponent %}
diff --git a/tera/src/snapshot_tests/parser_inputs/success/components/def/trim_optin.txt b/tera/src/snapshot_tests/parser_inputs/success/components/def/trim_optin.txt
new file mode 100644
index 000000000..b2ea89288
--- /dev/null
+++ b/tera/src/snapshot_tests/parser_inputs/success/components/def/trim_optin.txt
@@ -0,0 +1,3 @@
+{% component greet(label) {"trim": true} %}
+{{label}}
+{% endcomponent %}
diff --git a/tera/src/snapshot_tests/rendering_inputs/success/components/trim_body_def.txt b/tera/src/snapshot_tests/rendering_inputs/success/components/trim_body_def.txt
new file mode 100644
index 000000000..78911bc19
--- /dev/null
+++ b/tera/src/snapshot_tests/rendering_inputs/success/components/trim_body_def.txt
@@ -0,0 +1,9 @@
+$$ components
+{% component coverlink(dest) {"trim": true} %}
+{{ dest }}
+{% endcomponent coverlink %}
+$$ tpl
+
+{{}}
+{{}}
+
\ No newline at end of file
diff --git a/tera/src/snapshot_tests/rendering_inputs/success/components/trim_def_doesnt_touch_body.txt b/tera/src/snapshot_tests/rendering_inputs/success/components/trim_def_doesnt_touch_body.txt
new file mode 100644
index 000000000..32a8ad0a8
--- /dev/null
+++ b/tera/src/snapshot_tests/rendering_inputs/success/components/trim_def_doesnt_touch_body.txt
@@ -0,0 +1,8 @@
+$$ components
+{% component note() {"trim": true} %}
+
+{% endcomponent note %}
+$$ tpl
+{% %}
+ hello
+{% %}
\ No newline at end of file
diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_default.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_default.txt.snap
new file mode 100644
index 000000000..dfc2466ce
--- /dev/null
+++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_default.txt.snap
@@ -0,0 +1,23 @@
+---
+source: tera/src/snapshot_tests/parser.rs
+expression: "components[0]"
+input_file: tera/src/snapshot_tests/parser_inputs/success/components/def/trim_default.txt
+---
+ComponentDefinition {
+ name: "greet",
+ kwargs: {
+ "label": ComponentArgument {
+ default: None,
+ typ: None,
+ },
+ },
+ rest_param_name: None,
+ metadata: {},
+ body: [
+ "\n",
+ Var {
+ name: "label",
+ } @ 2:2-2:7 (31..36),
+ "\n",
+ ],
+}
diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_default_with_comment.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_default_with_comment.txt.snap
new file mode 100644
index 000000000..99f8bbc77
--- /dev/null
+++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_default_with_comment.txt.snap
@@ -0,0 +1,24 @@
+---
+source: tera/src/snapshot_tests/parser.rs
+expression: "components[0]"
+input_file: tera/src/snapshot_tests/parser_inputs/success/components/def/trim_default_with_comment.txt
+---
+ComponentDefinition {
+ name: "greet",
+ kwargs: {
+ "label": ComponentArgument {
+ default: None,
+ typ: None,
+ },
+ },
+ rest_param_name: None,
+ metadata: {},
+ body: [
+ "\n",
+ "\n",
+ Var {
+ name: "label",
+ } @ 3:2-3:7 (55..60),
+ "\n",
+ ],
+}
diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_optin.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_optin.txt.snap
new file mode 100644
index 000000000..7249c5e78
--- /dev/null
+++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_optin.txt.snap
@@ -0,0 +1,23 @@
+---
+source: tera/src/snapshot_tests/parser.rs
+expression: "components[0]"
+input_file: tera/src/snapshot_tests/parser_inputs/success/components/def/trim_optin.txt
+---
+ComponentDefinition {
+ name: "greet",
+ kwargs: {
+ "label": ComponentArgument {
+ default: None,
+ typ: None,
+ },
+ },
+ rest_param_name: None,
+ metadata: {
+ "trim": Bool(true),
+ },
+ body: [
+ Var {
+ name: "label",
+ } @ 2:2-2:7 (46..51),
+ ],
+}
diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_optout.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_optout.txt.snap
new file mode 100644
index 000000000..f9b83a4b3
--- /dev/null
+++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_components_definition_success@trim_optout.txt.snap
@@ -0,0 +1,25 @@
+---
+source: tera/src/snapshot_tests/parser.rs
+expression: "components[0]"
+input_file: tera/src/snapshot_tests/parser_inputs/success/components/def/trim_optout.txt
+---
+ComponentDefinition {
+ name: "greet",
+ kwargs: {
+ "label": ComponentArgument {
+ default: None,
+ typ: None,
+ },
+ },
+ rest_param_name: None,
+ metadata: {
+ "trim": Bool(false),
+ },
+ body: [
+ "\n",
+ Var {
+ name: "label",
+ } @ 2:2-2:7 (47..52),
+ "\n",
+ ],
+}
diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_components_ok@trim_body_def.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_components_ok@trim_body_def.txt.snap
new file mode 100644
index 000000000..ddb8b56b7
--- /dev/null
+++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_components_ok@trim_body_def.txt.snap
@@ -0,0 +1,9 @@
+---
+source: tera/src/snapshot_tests/rendering.rs
+expression: "&out"
+input_file: tera/src/snapshot_tests/rendering_inputs/success/components/trim_body_def.txt
+---
+
diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_components_ok@trim_def_doesnt_touch_body.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_components_ok@trim_def_doesnt_touch_body.txt.snap
new file mode 100644
index 000000000..2f6dcc755
--- /dev/null
+++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_components_ok@trim_def_doesnt_touch_body.txt.snap
@@ -0,0 +1,8 @@
+---
+source: tera/src/snapshot_tests/rendering.rs
+expression: "&out"
+input_file: tera/src/snapshot_tests/rendering_inputs/success/components/trim_def_doesnt_touch_body.txt
+---
+
diff --git a/tera/tests/snapshots/basic__rendering_realistic_benchmark.snap b/tera/tests/snapshots/basic__rendering_realistic_benchmark.snap
index 3581fd4cc..66c009603 100644
--- a/tera/tests/snapshots/basic__rendering_realistic_benchmark.snap
+++ b/tera/tests/snapshots/basic__rendering_realistic_benchmark.snap
@@ -21,9 +21,7 @@ expression: out
API Docs
-
-
-
+
- 1. Hello world
@@ -67,7 +65,6 @@ expression: out
-