Repository: djc/askama
Pull request #824 (2023-06-18 08:23:28)

Feature: Block fragments
Since https://github.com/djc/askama/pull/568 hasn't had an update in a long time, and it is a nice feature, this is a new PR to implement the proposed changes from that discussion.

This adds block fragments/partials as part of the Template derive. Here's an example of how to use it:

```rust
#[derive(Template)]
#[template(path = "...", block = "my_block")]
struct MyBlockFragment<'a> {
    name: &'a str,
}
```

This also properly supports inheritance, nesting, and the `super()` macro. It does this by abstracting `buf_writable` in the generator to add the ability to discard output, except for the block that needs to be rendered.

wrapperup: > Personally this feature seems a bit odd to me. It feels like its overloading the block inheritance mechanism to do something very different that there's already other ways to do.

I couldn't think of a cleaner way here. Perhaps adding another node to do it, but `block` ended up being suggested as the preferred way in that thread.

> I wonder if perhaps a more complete motivating example would be helpful, in the tests or the docs or just PR comments?

It's very much a convenience feature. There are already ways to do achieve the same result (macros, importing other templates). When using a library like `htmx` that send down small sections of html to update the page, it's pretty convenient.

It's part of this whole "locality of behavior" idea, where you keep things as close together as possible (ideally, in the same file). htmx, Alpine.js, Tailwindcss, and all of those that are HTML first trend towards this kind of design. This is also a pretty good read: https://htmx.org/essays/locality-of-behaviour/


wrapperup: The parser crate changes had no effect on this PR. Could I get eyes on these changes again?
wsantos: @djc what do we need to merge this? I'm considering using it but since my site is going to be built in htmx this is a requirement, thanks in advance.
nathanaelhuffman: I'm also considering using this with htmx and would love to see this land.
pbouzakis: Same here; looking to leverage this in htmx project. Currently, I'm using minijinja and render_block, which is working nicely. Would love to move to Askama if I can, but need this feature.
teenjuna: Hey @Kijewski, sorry to bother, but it looks like @djc is looking for your opinion on this. Can you please take a look? 🙏
djc: (No is also an okay answer, but would like to have that be made explicit!)
Kijewski: Sorry, I won't find the time to wrap my head around this PR for the foreseeable time. At a very (_very_) quick glance the code looks fine, and the feature seems useful to me.
James-Rhodes: Hi all is there any updates on this feature? Very much looking forward to seeing it land :) 
djc: This is waiting for another round of review from me now that we have some positive acknowledgement from @Kijewski (thanks!) -- I'm having a very busy time so it may take me a bit.

(If this is important to you, maybe consider [sponsoring](https://github.com/sponsors/djc) my work on Askama and other projects.)
GuillaumeGomez: The feature is pretty nice and code looks good to me! Let's wait for @djc to have time to come back to it.
djc: Please just rebase and squash all your changes -- right now "this branch cannot be rebased due to conflicts".
djc: (Failing CI now.)
Kijewski: The error is unrelated to this PR, and should be fixed in the current main branch. Can you please rebase, @wrapperup?
wrapperup: > I think the error is unrelated to this PR, and it should be fixed in the current main branch. Can you please rebase, @wrapperup?

Yep! Was just waiting on it to be fixed in main.

Patch:
diff --git a/askama_derive/src/generator.rs b/askama_derive/src/generator.rs
index 68d0012..df96c06 100644
--- a/askama_derive/src/generator.rs
+++ b/askama_derive/src/generator.rs
@@ -1,5 +1,6 @@
 use std::borrow::Cow;
 use std::collections::hash_map::{Entry, HashMap};
+use std::ops::Deref;
 use std::path::Path;
 use std::rc::Rc;
 use std::{cmp, hash, mem, str};
@@ -34,8 +35,8 @@ pub(crate) struct Generator<'a> {
     skip_ws: WhitespaceHandling,
     // If currently in a block, this will contain the name of a potential parent block
     super_block: Option<(&'a str, usize)>,
-    // buffer for writable
-    buf_writable: Vec<Writable<'a>>,
+    // Buffer for writable
+    buf_writable: WritableBuffer<'a>,
     // Counter for write! hash named arguments
     named: usize,
 }
@@ -55,7 +56,10 @@ impl<'a> Generator<'a> {
             next_ws: None,
             skip_ws: WhitespaceHandling::Preserve,
             super_block: None,
-            buf_writable: vec![],
+            buf_writable: WritableBuffer {
+                discard: input.block.is_some(),
+                ..Default::default()
+            },
             named: 0,
         }
     }
@@ -891,6 +895,13 @@ impl<'a> Generator<'a> {
         name: Option<&'a str>,
         outer: Ws,
     ) -> Result<usize, CompileError> {
+        let block_fragment_write = self.input.block == name && self.buf_writable.discard;
+
+        // Allow writing to the buffer if we're in the block fragment
+        if block_fragment_write {
+            self.buf_writable.discard = false;
+        }
+
         // Flush preceding whitespace according to the outer WS spec
         self.flush_ws(outer);
 
@@ -939,6 +950,12 @@ impl<'a> Generator<'a> {
         // succeeding whitespace according to the outer WS spec
         self.super_block = prev_block;
         self.prepare_ws(outer);
+
+        // Restore the original buffer discarding state
+        if block_fragment_write {
+            self.buf_writable.discard = true;
+        }
+
         Ok(size_hint)
     }
 
@@ -2055,6 +2072,37 @@ enum DisplayWrap {
     Unwrapped,
 }
 
+#[derive(Default, Debug)]
+struct WritableBuffer<'a> {
+    buf: Vec<Writable<'a>>,
+    discard: bool,
+}
+
+impl<'a> WritableBuffer<'a> {
+    fn push(&mut self, writable: Writable<'a>) {
+        if !self.discard {
+            self.buf.push(writable);
+        }
+    }
+}
+
+impl<'a> IntoIterator for WritableBuffer<'a> {
+    type Item = Writable<'a>;
+    type IntoIter = <Vec<Writable<'a>> as IntoIterator>::IntoIter;
+
+    fn into_iter(self) -> Self::IntoIter {
+        self.buf.into_iter()
+    }
+}
+
+impl<'a> Deref for WritableBuffer<'a> {
+    type Target = [Writable<'a>];
+
+    fn deref(&self) -> &Self::Target {
+        &self.buf[..]
+    }
+}
+
 #[derive(Debug)]
 enum Writable<'a> {
     Lit(&'a str),
diff --git a/askama_derive/src/input.rs b/askama_derive/src/input.rs
index 462e73b..989db1d 100644
--- a/askama_derive/src/input.rs
+++ b/askama_derive/src/input.rs
@@ -16,6 +16,7 @@ pub(crate) struct TemplateInput<'a> {
     pub(crate) config: &'a Config<'a>,
     pub(crate) syntax: &'a Syntax<'a>,
     pub(crate) source: &'a Source,
+    pub(crate) block: Option<&'a str>,
     pub(crate) print: Print,
     pub(crate) escaper: &'a str,
     pub(crate) ext: Option<&'a str>,
@@ -34,6 +35,7 @@ impl TemplateInput<'_> {
     ) -> Result<TemplateInput<'n>, CompileError> {
         let TemplateArgs {
             source,
+            block,
             print,
             escaping,
             ext,
@@ -95,6 +97,7 @@ impl TemplateInput<'_> {
             config,
             syntax,
             source,
+            block: block.as_deref(),
             print: *print,
             escaper,
             ext: ext.as_deref(),
@@ -210,6 +213,7 @@ impl TemplateInput<'_> {
 #[derive(Debug, Default)]
 pub(crate) struct TemplateArgs {
     source: Option<Source>,
+    block: Option<String>,
     print: Print,
     escaping: Option<String>,
     ext: Option<String>,
@@ -288,6 +292,12 @@ impl TemplateArgs {
                 } else {
                     return Err("template source must be string literal".into());
                 }
+            } else if ident == "block" {
+                if let syn::Lit::Str(s) = value.lit {
+                    args.block = Some(s.value());
+                } else {
+                    return Err("block value must be string literal".into());
+                }
             } else if ident == "print" {
                 if let syn::Lit::Str(s) = value.lit {
                     args.print = s.value().parse()?;
diff --git a/askama_derive/src/lib.rs b/askama_derive/src/lib.rs
index 2f1626a..96c1447 100644
--- a/askama_derive/src/lib.rs
+++ b/askama_derive/src/lib.rs
@@ -66,7 +66,15 @@ pub(crate) fn build_template(ast: &syn::DeriveInput) -> Result<String, CompileEr
 
     let ctx = &contexts[&input.path];
     let heritage = if !ctx.blocks.is_empty() || ctx.extends.is_some() {
-        Some(Heritage::new(ctx, &contexts))
+        let heritage = Heritage::new(ctx, &contexts);
+
+        if let Some(block_name) = input.block {
+            if !heritage.blocks.contains_key(&block_name) {
+                return Err(format!("cannot find block {}", block_name).into());
+            }
+        }
+
+        Some(heritage)
     } else {
         None
     };
diff --git a/askama_parser/src/lib.rs b/askama_parser/src/lib.rs
index d80e4f0..43b604d 100644
--- a/askama_parser/src/lib.rs
+++ b/askama_parser/src/lib.rs
@@ -549,13 +549,13 @@ fn strip_common(base: &Path, path: &Path) -> String {
     }
 }
 
-#[cfg(not(windows))]
 #[cfg(test)]
 mod test {
-    use super::{num_lit, strip_common};
+    use super::strip_common;
     use std::path::Path;
 
     #[test]
+    #[cfg(not(windows))]
     fn test_strip_common() {
         let cwd = std::env::current_dir().expect("current_dir failed");
 
@@ -584,19 +584,4 @@ mod test {
         // In this case it cannot canonicalize `/a/b/c` so it returns the path as is.
         assert_eq!(strip_common(&cwd, Path::new("/a/b/c")), "/a/b/c");
     }
-
-    #[test]
-    fn test_num_lit() {
-        // Should fail.
-        assert!(num_lit(".").is_err());
-        // Should succeed.
-        assert_eq!(num_lit("1.2E-02").unwrap(), ("", "1.2E-02"));
-        // Not supported (because rust want a number before the `.`).
-        assert!(num_lit(".1").is_err());
-        assert!(num_lit(".1E-02").is_err());
-        // Not supported (voluntarily because of `1..` syntax).
-        assert_eq!(num_lit("1.").unwrap(), (".", "1"));
-        assert_eq!(num_lit("1_.").unwrap(), (".", "1_"));
-        assert_eq!(num_lit("1_2.").unwrap(), (".", "1_2"));
-    }
 }
diff --git a/book/src/creating_templates.md b/book/src/creating_templates.md
index e087ede..ee3ac6b 100644
--- a/book/src/creating_templates.md
+++ b/book/src/creating_templates.md
@@ -75,6 +75,16 @@ recognized:
   #[template(path = "hello.html", print = "all")]
   struct HelloTemplate<'a> { ... }
   ```
+* `block` (as `block = "block_name"`): renders the block by itself.
+  Expressions outside of the block are not required by the struct, and
+  inheritance is also supported. This can be useful when you need to
+  decompose your template for partial rendering, without needing to
+  extract the partial into a separate template or macro.
+  ```rust
+  #[derive(Template)]
+  #[template(path = "hello.html", block = "hello")]
+  struct HelloTemplate<'a> { ... }
+  ```
 * `escape` (as `escape = "none"`): override the template's extension used for
   the purpose of determining the escaper for this template. See the section
   on configuring custom escapers for more information.
diff --git a/book/src/template_syntax.md b/book/src/template_syntax.md
index fcfe738..c5b9ab0 100644
--- a/book/src/template_syntax.md
+++ b/book/src/template_syntax.md
@@ -362,6 +362,21 @@ tag doesn't support whitespace control:
 The above code is rejected because we used `-` and `+`. For more information
 about whitespace control, take a look [here](#whitespace-control).
 
+### Block fragments
+
+Additionally, a block can be rendered by itself. This can be useful when
+you need to decompose your template for partial rendering, without
+needing to extract the partial into a separate template or macro. This
+can be done with the `block` parameter.
+
+```rust
+#[derive(Template)]
+#[template(path = "...", block = "my_block")]
+struct BlockFragment {
+    name: String,
+}
+```
+
 ## HTML escaping
 
 Askama by default escapes variables if it thinks it is rendering HTML
@@ -457,7 +472,7 @@ Additionally, `if let` statements are also supported and similarly
 mirror Rust's [`if let` expressions]:
 
 ```text
-{% if let Some(user) = user %}
+{% if Some(user) = user %}
   {{ user.name }}
 {% else %}
   No user


Test patch:
diff --git a/testing/templates/fragment-base.html b/testing/templates/fragment-base.html
new file mode 100644
index 0000000..01b7bd5
--- /dev/null
+++ b/testing/templates/fragment-base.html
@@ -0,0 +1,9 @@
+<html>
+<head></head>
+<body>
+{% block body %}
+<p>Parent body content</p>
+{% endblock %}
+{% block other_body %}{% endblock %}
+</body>
+</html>
diff --git a/testing/templates/fragment-mid-super.html b/testing/templates/fragment-mid-super.html
new file mode 100644
index 0000000..d38c70c
--- /dev/null
+++ b/testing/templates/fragment-mid-super.html
@@ -0,0 +1,9 @@
+{% extends "fragment-base.html" %}
+
+{% block body %}
+[{% call super() %}]
+{% endblock %}
+
+{% block other_body %}
+({% call super() %})
+{% endblock %}
diff --git a/testing/templates/fragment-nested-block.html b/testing/templates/fragment-nested-block.html
new file mode 100644
index 0000000..a066625
--- /dev/null
+++ b/testing/templates/fragment-nested-block.html
@@ -0,0 +1,13 @@
+{% extends "fragment-base.html" %}
+
+{% block body %}
+<p>Don't render me!</p>
+{% block nested %}
+<p>I should be here.</p>
+{% endblock %}
+{% endblock %}
+
+{% block other_body %}
+<p>Don't render me!</p>
+{% endblock %}
+
diff --git a/testing/templates/fragment-nested-super.html b/testing/templates/fragment-nested-super.html
new file mode 100644
index 0000000..e4b0f76
--- /dev/null
+++ b/testing/templates/fragment-nested-super.html
@@ -0,0 +1,12 @@
+{% extends "fragment-mid-super.html" %}
+
+{% block body %}
+<p>Hello {{ name }}!</p>
+{% call super() %}
+{% endblock %}
+
+{% block other_body %}
+<p>Don't render me.</p>
+{% call super() %}
+{% endblock %}
+
diff --git a/testing/templates/fragment-simple.html b/testing/templates/fragment-simple.html
new file mode 100644
index 0000000..8483888
--- /dev/null
+++ b/testing/templates/fragment-simple.html
@@ -0,0 +1,9 @@
+{% extends "fragment-base.html" %}
+
+{% block body %}
+<p>Hello {{ name }}!</p>
+{% endblock %}
+
+{% block other_body %}
+<p>Don't render me.</p>
+{% endblock %}
diff --git a/testing/templates/fragment-super.html b/testing/templates/fragment-super.html
new file mode 100644
index 0000000..d992cbc
--- /dev/null
+++ b/testing/templates/fragment-super.html
@@ -0,0 +1,12 @@
+{% extends "fragment-base.html" %}
+
+{% block body %}
+<p>Hello {{ name }}!</p>
+{% call super() %}
+{% endblock %}
+
+{% block other_body %}
+<p>Don't render me.</p>
+{% call super() %}
+{% endblock %}
+
diff --git a/testing/templates/fragment-unused-expr.html b/testing/templates/fragment-unused-expr.html
new file mode 100644
index 0000000..1759082
--- /dev/null
+++ b/testing/templates/fragment-unused-expr.html
@@ -0,0 +1,12 @@
+{% extends "fragment-base.html" %}
+
+{{ not_required }}
+
+{% block body %}
+<p>{{ required }}</p>
+{% endblock %}
+
+{% block other_body %}
+{{ not_required_2 }}
+<p>Don't render me.</p>
+{% endblock %}
diff --git a/testing/templates/num-literals.html b/testing/templates/num-literals.html
index 354d8dc..90b07db 100644
--- a/testing/templates/num-literals.html
+++ b/testing/templates/num-literals.html
@@ -9,9 +9,3 @@
 {% let exp_float = 1__0__e+__1__0__f32 -%}
 {% let dotexp_float = 1__0__.5__0__e+__1__0__f32 -%}
 [{{ plain_int }}, {{ neg_int }}, {{ suffix_int }}, {{ bin_int }}, {{ oct_int }}, {{ hex_int }}, {{ plain_float }}, {{ suffix_float }}, {{ exp_float }}, {{ dotexp_float }}]
-{% for value in 1..2 -%}
-    {{ value }}
-{%- endfor %}
-{% for value in 1..=2 -%}
-    {{ value }}
-{%- endfor %}
diff --git a/testing/tests/block_fragments.rs b/testing/tests/block_fragments.rs
new file mode 100644
index 0000000..b84a511
--- /dev/null
+++ b/testing/tests/block_fragments.rs
@@ -0,0 +1,83 @@
+use askama::Template;
+
+#[derive(Template)]
+#[template(path = "fragment-simple.html", block = "body")]
+struct FragmentSimple<'a> {
+    name: &'a str,
+}
+
+/// Tests a simple base-inherited template with block fragment rendering.
+#[test]
+fn test_fragment_simple() {
+    let simple = FragmentSimple { name: "world" };
+
+    assert_eq!(simple.render().unwrap(), "\n\n<p>Hello world!</p>\n");
+}
+
+#[derive(Template)]
+#[template(path = "fragment-super.html", block = "body")]
+struct FragmentSuper<'a> {
+    name: &'a str,
+}
+
+/// Tests a case where a block fragment rendering calls the parent.
+/// Single inheritance only.
+#[test]
+fn test_fragment_super() {
+    let sup = FragmentSuper { name: "world" };
+
+    assert_eq!(
+        sup.render().unwrap(),
+        "\n\n<p>Hello world!</p>\n\n<p>Parent body content</p>\n\n"
+    );
+}
+
+#[derive(Template)]
+#[template(path = "fragment-nested-block.html", block = "nested")]
+struct FragmentNestedBlock;
+
+/// Tests rendering a block fragment inside of a block.
+#[test]
+fn test_fragment_nested_block() {
+    let nested_block = FragmentNestedBlock {};
+
+    assert_eq!(
+        nested_block.render().unwrap(),
+        "\n\n<p>I should be here.</p>\n"
+    );
+}
+
+#[derive(Template)]
+#[template(path = "fragment-nested-super.html", block = "body")]
+struct FragmentNestedSuper<'a> {
+    name: &'a str,
+}
+
+/// Tests rendering a block fragment with multiple inheritance.
+/// The middle parent adds square brackets around the base.
+#[test]
+fn test_fragment_nested_super() {
+    let nested_sup = FragmentNestedSuper { name: "world" };
+
+    assert_eq!(
+        nested_sup.render().unwrap(),
+        "\n\n<p>Hello world!</p>\n\n[\n<p>Parent body content</p>\n]\n\n"
+    );
+}
+
+#[derive(Template)]
+#[template(path = "fragment-unused-expr.html", block = "body")]
+struct FragmentUnusedExpr<'a> {
+    required: &'a str,
+}
+
+/// Tests a case where an expression is defined outside of a block fragment
+/// Ideally, the struct isn't required to define that field.
+#[test]
+fn test_fragment_unused_expression() {
+    let unused_expr = FragmentUnusedExpr {
+        required: "Required",
+    };
+
+    assert_eq!(unused_expr.render().unwrap(), "\n\n<p>Required</p>\n");
+}
diff --git a/testing/tests/simple.rs b/testing/tests/simple.rs
index a155fac..bc552cd 100644
--- a/testing/tests/simple.rs
+++ b/testing/tests/simple.rs
@@ -481,7 +481,7 @@ fn test_num_literals() {
     let template = NumLiterals;
     assert_eq!(
         template.render().unwrap(),
-        "[90, -90, 90, 2, 56, 240, 10.5, 10.5, 100000000000, 105000000000]\n1\n12",
+        "[90, -90, 90, 2, 56, 240, 10.5, 10.5, 100000000000, 105000000000]",
     );
 }