Repository: djc/askama
Pull request #988 (2024-03-11 19:44:21)

Correctly handle whitespace character(s) on filter blocks
"Funny" bug I encountered: the whitespace characters are not handled correctly.

GuillaumeGomez: Fixed and added a ui test for that.
GuillaumeGomez: Fixed the test.

Patch:
diff --git a/askama/Cargo.toml b/askama/Cargo.toml
index c05fecf..9ab4898 100644
--- a/askama/Cargo.toml
+++ b/askama/Cargo.toml
@@ -20,10 +20,8 @@ maintenance = { status = "actively-developed" }
 default = ["config", "humansize", "num-traits", "urlencode"]
 config = ["askama_derive/config"]
 humansize = ["askama_derive/humansize", "dep_humansize"]
-markdown = ["askama_derive/markdown", "comrak"]
 num-traits = ["askama_derive/num-traits", "dep_num_traits"]
 serde-json = ["askama_derive/serde-json", "askama_escape/json", "serde", "serde_json"]
-serde-yaml = ["askama_derive/serde-yaml", "serde", "serde_yaml"]
 urlencode = ["askama_derive/urlencode", "percent-encoding"]
 with-actix-web = ["askama_derive/with-actix-web"]
 with-axum = ["askama_derive/with-axum"]
@@ -37,13 +35,11 @@ mime_guess = []
 [dependencies]
 askama_derive = { version = "0.13", path = "../askama_derive" }
 askama_escape = { version = "0.10.3", path = "../askama_escape" }
-comrak = { version = "0.21", optional = true, default-features = false }
 dep_humansize = { package = "humansize", version = "2", optional = true }
 dep_num_traits = { package = "num-traits", version = "0.2.6", optional = true }
 percent-encoding = { version = "2.1.0", optional = true }
 serde = { version = "1.0", optional = true, features = ["derive"] }
 serde_json = { version = "1.0", optional = true }
-serde_yaml = { version = "0.9", optional = true }
 
 [package.metadata.docs.rs]
-features = ["config", "humansize", "num-traits", "serde-json", "serde-yaml"]
+features = ["config", "humansize", "num-traits", "serde-json"]
diff --git a/askama/src/error.rs b/askama/src/error.rs
index 406b148..affbc4c 100644
--- a/askama/src/error.rs
+++ b/askama/src/error.rs
@@ -34,10 +34,6 @@ pub enum Error {
     /// json conversion error
     #[cfg(feature = "serde_json")]
     Json(::serde_json::Error),
-
-    /// yaml conversion error
-    #[cfg(feature = "serde_yaml")]
-    Yaml(::serde_yaml::Error),
 }
 
 impl std::error::Error for Error {
@@ -47,8 +43,6 @@ impl std::error::Error for Error {
             Error::Custom(ref err) => Some(err.as_ref()),
             #[cfg(feature = "serde_json")]
             Error::Json(ref err) => Some(err),
-            #[cfg(feature = "serde_yaml")]
-            Error::Yaml(ref err) => Some(err),
         }
     }
 }
@@ -60,8 +54,6 @@ impl Display for Error {
             Error::Custom(err) => write!(formatter, "{err}"),
             #[cfg(feature = "serde_json")]
             Error::Json(err) => write!(formatter, "json conversion error: {err}"),
-            #[cfg(feature = "serde_yaml")]
-            Error::Yaml(err) => write!(formatter, "yaml conversion error: {}", err),
         }
     }
 }
@@ -79,17 +71,11 @@ impl From<::serde_json::Error> for Error {
     }
 }
 
-#[cfg(feature = "serde_yaml")]
-impl From<::serde_yaml::Error> for Error {
-    fn from(err: ::serde_yaml::Error) -> Self {
-        Error::Yaml(err)
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::Error;
 
+    #[allow(dead_code)]
     trait AssertSendSyncStatic: Send + Sync + 'static {}
     impl AssertSendSyncStatic for Error {}
 }
diff --git a/askama/src/filters/mod.rs b/askama/src/filters/mod.rs
index f77e2f8..04a6948 100644
--- a/askama/src/filters/mod.rs
+++ b/askama/src/filters/mod.rs
@@ -12,11 +12,6 @@ mod json;
 #[cfg(feature = "serde-json")]
 pub use self::json::json;
 
-#[cfg(feature = "serde-yaml")]
-mod yaml;
-#[cfg(feature = "serde-yaml")]
-pub use self::yaml::yaml;
-
 #[allow(unused_imports)]
 use crate::error::Error::Fmt;
 use askama_escape::{Escaper, MarkupDisplay};
@@ -341,29 +336,6 @@ pub fn wordcount<T: fmt::Display>(s: T) -> Result<usize> {
     Ok(s.split_whitespace().count())
 }
 
-#[cfg(feature = "markdown")]
-pub fn markdown<E, S>(
-    e: E,
-    s: S,
-    options: Option<&comrak::Options>,
-) -> Result<MarkupDisplay<E, String>>
-where
-    E: Escaper,
-    S: AsRef<str>,
-{
-    use comrak::{markdown_to_html, Options};
-
-    let mut defaults = Options::default();
-    defaults.extension.strikethrough = true;
-    defaults.extension.tagfilter = true;
-    defaults.extension.table = true;
-    defaults.extension.autolink = true;
-    defaults.render.escape = true;
-
-    let s = markdown_to_html(s.as_ref(), options.unwrap_or(&defaults));
-    Ok(MarkupDisplay::new_safe(s, e))
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/askama/src/filters/yaml.rs b/askama/src/filters/yaml.rs
deleted file mode 100644
index 9f4c802..0000000
--- a/askama/src/filters/yaml.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-use crate::error::{Error, Result};
-use askama_escape::{Escaper, MarkupDisplay};
-use serde::Serialize;
-
-/// Serialize to YAML (requires `serde_yaml` feature)
-///
-/// ## Errors
-///
-/// This will panic if `S`'s implementation of `Serialize` decides to fail,
-/// or if `T` contains a map with non-string keys.
-pub fn yaml<E: Escaper, S: Serialize>(e: E, s: S) -> Result<MarkupDisplay<E, String>> {
-    match serde_yaml::to_string(&s) {
-        Ok(s) => Ok(MarkupDisplay::new_safe(s, e)),
-        Err(e) => Err(Error::from(e)),
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use askama_escape::Html;
-
-    #[test]
-    fn test_yaml() {
-        assert_eq!(yaml(Html, true).unwrap().to_string(), "true\n");
-        assert_eq!(yaml(Html, "foo").unwrap().to_string(), "foo\n");
-        assert_eq!(yaml(Html, true).unwrap().to_string(), "true\n");
-        assert_eq!(yaml(Html, "foo").unwrap().to_string(), "foo\n");
-        assert_eq!(
-            yaml(Html, &vec!["foo", "bar"]).unwrap().to_string(),
-            "- foo\n- bar\n"
-        );
-    }
-}
diff --git a/askama_actix/Cargo.toml b/askama_actix/Cargo.toml
index ddb23b1..3dd3607 100644
--- a/askama_actix/Cargo.toml
+++ b/askama_actix/Cargo.toml
@@ -26,8 +26,6 @@ bytes = { version = "1" }
 default = ["askama/default"]
 config = ["askama/config"]
 humansize = ["askama/humansize"]
-markdown = ["askama/markdown"]
 num-traits = ["askama/num-traits"]
 serde-json = ["askama/serde-json"]
-serde-yaml = ["askama/serde-yaml"]
 urlencode = ["askama/urlencode"]
diff --git a/askama_axum/Cargo.toml b/askama_axum/Cargo.toml
index f8a77b1..aee905a 100644
--- a/askama_axum/Cargo.toml
+++ b/askama_axum/Cargo.toml
@@ -14,23 +14,20 @@ workspace = ".."
 readme = "README.md"
 
 [dependencies]
-askama = { version = "0.13", path = "../askama", default-features = false, features = ["with-axum", "mime", "mime_guess"] }
+askama = { version = "0.13", path = "../askama", default-features = false, features = ["with-axum"] }
 axum-core = "0.4"
 http = "1.0"
 
 [dev-dependencies]
 axum = { version = "0.7", default-features = false }
 http-body-util = "0.1"
-hyper = { version = "1.0", features = ["full"] }
-tokio = { version = "1.0", features = ["full"] }
-tower = { version = "0.4", features = ["util"] }
+tokio = { version = "1.0", features = ["macros", "rt"] }
+tower = "0.4"
 
 [features]
 default = ["askama/default"]
 config = ["askama/config"]
 humansize = ["askama/humansize"]
-markdown = ["askama/markdown"]
 num-traits = ["askama/num-traits"]
 serde-json = ["askama/serde-json"]
-serde-yaml = ["askama/serde-yaml"]
 urlencode = ["askama/urlencode"]
diff --git a/askama_derive/Cargo.toml b/askama_derive/Cargo.toml
index 1afb51c..a476857 100644
--- a/askama_derive/Cargo.toml
+++ b/askama_derive/Cargo.toml
@@ -16,10 +16,8 @@ proc-macro = true
 [features]
 config = ["serde", "basic-toml"]
 humansize = []
-markdown = []
 urlencode = []
 serde-json = []
-serde-yaml = []
 num-traits = []
 with-actix-web = []
 with-axum = []
diff --git a/askama_derive/src/config.rs b/askama_derive/src/config.rs
index c5ce1b4..78ab5f5 100644
--- a/askama_derive/src/config.rs
+++ b/askama_derive/src/config.rs
@@ -1,5 +1,6 @@
 use std::collections::{BTreeMap, HashSet};
 use std::path::{Path, PathBuf};
+use std::rc::Rc;
 use std::{env, fs};
 
 #[cfg(feature = "serde")]
@@ -109,18 +110,18 @@ impl<'a> Config<'a> {
         &self,
         path: &str,
         start_at: Option<&Path>,
-    ) -> std::result::Result<PathBuf, CompileError> {
+    ) -> std::result::Result<Rc<Path>, CompileError> {
         if let Some(root) = start_at {
             let relative = root.with_file_name(path);
             if relative.exists() {
-                return Ok(relative);
+                return Ok(relative.into());
             }
         }
 
         for dir in &self.dirs {
             let rooted = dir.join(path);
             if rooted.exists() {
-                return Ok(rooted);
+                return Ok(rooted.into());
             }
         }
 
diff --git a/askama_derive/src/generator.rs b/askama_derive/src/generator.rs
index c2d5017..f4a1188 100644
--- a/askama_derive/src/generator.rs
+++ b/askama_derive/src/generator.rs
@@ -1,6 +1,7 @@
 use std::borrow::Cow;
 use std::collections::hash_map::{Entry, HashMap};
 use std::path::Path;
+use std::rc::Rc;
 use std::{cmp, hash, mem, str};
 
 use crate::config::WhitespaceHandling;
@@ -19,7 +20,7 @@ pub(crate) struct Generator<'a> {
     // The template input state: original struct AST and attributes
     input: &'a TemplateInput<'a>,
     // All contexts, keyed by the package-relative template path
-    contexts: &'a HashMap<&'a Path, Context<'a>>,
+    contexts: &'a HashMap<&'a Rc<Path>, Context<'a>>,
     // The heritage contains references to blocks and their ancestry
     heritage: Option<&'a Heritage<'a>>,
     // Variables accessible directly from the current scope (not redirected to context)
@@ -42,7 +43,7 @@ pub(crate) struct Generator<'a> {
 impl<'a> Generator<'a> {
     pub(crate) fn new<'n>(
         input: &'n TemplateInput<'_>,
-        contexts: &'n HashMap<&'n Path, Context<'n>>,
+        contexts: &'n HashMap<&'n Rc<Path>, Context<'n>>,
         heritage: Option<&'n Heritage<'_>>,
         locals: MapChain<'n, Cow<'n, str>, LocalMeta>,
     ) -> Generator<'n> {
@@ -94,7 +95,7 @@ impl<'a> Generator<'a> {
             // Skip the fake path of templates defined in rust source.
             let path_is_valid = match self.input.source {
                 Source::Path(_) => true,
-                Source::Source(_) => path != &self.input.path,
+                Source::Source(_) => **path != self.input.path,
             };
             if path_is_valid {
                 let path = path.to_str().unwrap();
@@ -559,7 +560,7 @@ impl<'a> Generator<'a> {
                 })?;
                 let mctx = self
                     .contexts
-                    .get(path.as_path())
+                    .get(path)
                     .ok_or_else(|| CompileError::from(format!("context for {path:?} not found")))?;
                 let def = mctx.macros.get(name).ok_or_else(|| {
                     CompileError::from(format!("macro {name:?} not found in scope {s:?}"))
@@ -784,7 +785,7 @@ impl<'a> Generator<'a> {
         // We clone the context of the child in order to preserve their macros and imports.
         // But also add all the imports and macros from this template that don't override the
         // child's ones to preserve this template's context.
-        let child_ctx = &mut self.contexts[path.as_path()].clone();
+        let child_ctx = &mut self.contexts[&path].clone();
         for (name, mac) in &ctx.macros {
             child_ctx.macros.entry(name).or_insert(mac);
         }
@@ -1186,45 +1187,6 @@ impl<'a> Generator<'a> {
         DisplayWrap::Unwrapped
     }
 
-    #[cfg(not(feature = "markdown"))]
-    fn _visit_markdown_filter(
-        &mut self,
-        _buf: &mut Buffer,
-        _args: &[Expr<'_>],
-    ) -> Result<DisplayWrap, CompileError> {
-        Err("the `markdown` filter requires the `markdown` feature to be enabled".into())
-    }
-
-    #[cfg(feature = "markdown")]
-    fn _visit_markdown_filter(
-        &mut self,
-        buf: &mut Buffer,
-        args: &[Expr<'_>],
-    ) -> Result<DisplayWrap, CompileError> {
-        let (md, options) = match args {
-            [md] => (md, None),
-            [md, options] => (md, Some(options)),
-            _ => return Err("markdown filter expects no more than one option argument".into()),
-        };
-
-        buf.write(&format!(
-            "{CRATE}::filters::markdown({}, &",
-            self.input.escaper
-        ));
-        self.visit_expr(buf, md)?;
-        match options {
-            Some(options) => {
-                buf.write(", ::core::option::Option::Some(");
-                self.visit_expr(buf, options)?;
-                buf.write(")");
-            }
-            None => buf.write(", ::core::option::Option::None"),
-        }
-        buf.write(")?");
-
-        Ok(DisplayWrap::Wrapped)
-    }
-
     fn _visit_as_ref_filter(
         &mut self,
         buf: &mut Buffer,
@@ -1257,8 +1219,6 @@ impl<'a> Generator<'a> {
         } else if name == "join" {
             self._visit_join_filter(buf, args)?;
             return Ok(DisplayWrap::Unwrapped);
-        } else if name == "markdown" {
-            return self._visit_markdown_filter(buf, args);
         } else if name == "as_ref" {
             self._visit_as_ref_filter(buf, args)?;
             return Ok(DisplayWrap::Wrapped);
@@ -1272,29 +1232,24 @@ impl<'a> Generator<'a> {
         if name == "json" {
             return Err("the `json` filter requires the `serde-json` feature to be enabled".into());
         }
-        #[cfg(not(feature = "serde-yaml"))]
-        if name == "yaml" {
-            return Err("the `yaml` filter requires the `serde-yaml` feature to be enabled".into());
-        }
 
-        const FILTERS: [&str; 2] = ["safe", "yaml"];
-        if FILTERS.contains(&name) {
+        let display_wrap = if name == "safe" {
             buf.write(&format!(
                 "{CRATE}::filters::{}({}, ",
                 name, self.input.escaper
             ));
+            DisplayWrap::Wrapped
         } else if crate::BUILT_IN_FILTERS.contains(&name) {
             buf.write(&format!("{CRATE}::filters::{name}("));
+            DisplayWrap::Unwrapped
         } else {
             buf.write(&format!("filters::{name}("));
-        }
+            DisplayWrap::Unwrapped
+        };
 
         self._visit_args(buf, args)?;
         buf.write(")?");
-        Ok(match FILTERS.contains(&name) {
-            true => DisplayWrap::Wrapped,
-            false => DisplayWrap::Unwrapped,
-        })
+        Ok(display_wrap)
     }
 
     fn _visit_escape_filter(
diff --git a/askama_derive/src/heritage.rs b/askama_derive/src/heritage.rs
index bded66b..2d70c08 100644
--- a/askama_derive/src/heritage.rs
+++ b/askama_derive/src/heritage.rs
@@ -1,5 +1,6 @@
 use std::collections::HashMap;
-use std::path::{Path, PathBuf};
+use std::path::Path;
+use std::rc::Rc;
 
 use crate::config::Config;
 use crate::CompileError;
@@ -14,7 +15,7 @@ pub(crate) struct Heritage<'a> {
 impl Heritage<'_> {
     pub(crate) fn new<'n>(
         mut ctx: &'n Context<'n>,
-        contexts: &'n HashMap<&'n Path, Context<'n>>,
+        contexts: &'n HashMap<&'n Rc<Path>, Context<'n>>,
     ) -> Heritage<'n> {
         let mut blocks: BlockAncestry<'n> = ctx
             .blocks
@@ -22,8 +23,8 @@ impl Heritage<'_> {
             .map(|(name, def)| (*name, vec![(ctx, *def)]))
             .collect();
 
-        while let Some(ref path) = ctx.extends {
-            ctx = &contexts[path.as_path()];
+        while let Some(path) = &ctx.extends {
+            ctx = &contexts[path];
             for (name, def) in &ctx.blocks {
                 blocks.entry(name).or_default().push((ctx, def));
             }
@@ -38,10 +39,10 @@ type BlockAncestry<'a> = HashMap<&'a str, Vec<(&'a Context<'a>, &'a BlockDef<'a>
 #[derive(Clone)]
 pub(crate) struct Context<'a> {
     pub(crate) nodes: &'a [Node<'a>],
-    pub(crate) extends: Option<PathBuf>,
+    pub(crate) extends: Option<Rc<Path>>,
     pub(crate) blocks: HashMap<&'a str, &'a BlockDef<'a>>,
     pub(crate) macros: HashMap<&'a str, &'a Macro<'a>>,
-    pub(crate) imports: HashMap<&'a str, PathBuf>,
+    pub(crate) imports: HashMap<&'a str, Rc<Path>>,
 }
 
 impl Context<'_> {
diff --git a/askama_derive/src/input.rs b/askama_derive/src/input.rs
index 7d2d74b..4139ca8 100644
--- a/askama_derive/src/input.rs
+++ b/askama_derive/src/input.rs
@@ -1,5 +1,6 @@
 use std::collections::hash_map::HashMap;
 use std::path::{Path, PathBuf};
+use std::rc::Rc;
 use std::str::FromStr;
 
 use mime::Mime;
@@ -19,7 +20,7 @@ pub(crate) struct TemplateInput<'a> {
     pub(crate) escaper: &'a str,
     pub(crate) ext: Option<&'a str>,
     pub(crate) mime_type: String,
-    pub(crate) path: PathBuf,
+    pub(crate) path: Rc<Path>,
 }
 
 impl TemplateInput<'_> {
@@ -48,7 +49,9 @@ impl TemplateInput<'_> {
             .expect("template path or source not found in attributes");
         let path = match (&source, &ext) {
             (Source::Path(path), _) => config.find_template(path, None)?,
-            (&Source::Source(_), Some(ext)) => PathBuf::from(format!("{}.{}", ast.ident, ext)),
+            (&Source::Source(_), Some(ext)) => {
+                PathBuf::from(format!("{}.{}", ast.ident, ext)).into()
+            }
             (&Source::Source(_), None) => {
                 return Err("must include 'ext' attribute when using 'source' attribute".into())
             }
@@ -102,29 +105,32 @@ impl TemplateInput<'_> {
 
     pub(crate) fn find_used_templates(
         &self,
-        map: &mut HashMap<PathBuf, Parsed>,
+        map: &mut HashMap<Rc<Path>, Parsed>,
     ) -> Result<(), CompileError> {
-        let source = match &self.source {
-            Source::Source(s) => s.clone(),
-            Source::Path(_) => get_template_source(&self.path)?,
+        let (source, source_path) = match &self.source {
+            Source::Source(s) => (s.into(), None),
+            Source::Path(_) => (
+                get_template_source(&self.path)?,
+                Some(Rc::clone(&self.path)),
+            ),
         };
 
         let mut dependency_graph = Vec::new();
-        let mut check = vec![(self.path.clone(), source)];
-        while let Some((path, source)) = check.pop() {
-            let parsed = Parsed::new(source, self.syntax)?;
+        let mut check = vec![(Rc::clone(&self.path), source, source_path)];
+        while let Some((path, source, source_path)) = check.pop() {
+            let parsed = Parsed::new(source, source_path, self.syntax)?;
 
             let mut top = true;
             let mut nested = vec![parsed.nodes()];
             while let Some(nodes) = nested.pop() {
                 for n in nodes {
-                    let mut add_to_check = |path: PathBuf| -> Result<(), CompileError> {
+                    let mut add_to_check = |path: Rc<Path>| -> Result<(), CompileError> {
                         if !map.contains_key(&path) {
                             // Add a dummy entry to `map` in order to prevent adding `path`
                             // multiple times to `check`.
-                            map.insert(path.clone(), Parsed::default());
+                            map.insert(Rc::clone(&path), Parsed::default());
                             let source = get_template_source(&path)?;
-                            check.push((path, source));
+                            check.push((path.clone(), source, Some(path)));
                         }
                         Ok(())
                     };
@@ -134,15 +140,12 @@ impl TemplateInput<'_> {
                         Extends(extends) if top => {
                             let extends = self.config.find_template(extends.path, Some(&path))?;
                             let dependency_path = (path.clone(), extends.clone());
-                            if dependency_graph.contains(&dependency_path) {
-                                return Err(format!(
-                                    "cyclic dependency in graph {:#?}",
-                                    dependency_graph
-                                        .iter()
-                                        .map(|e| format!("{:#?} --> {:#?}", e.0, e.1))
-                                        .collect::<Vec<String>>()
-                                )
-                                .into());
+                            if path == extends {
+                                // We add the path into the graph to have a better looking error.
+                                dependency_graph.push(dependency_path);
+                                return cyclic_graph_error(&dependency_graph);
+                            } else if dependency_graph.contains(&dependency_path) {
+                                return cyclic_graph_error(&dependency_graph);
                             }
                             dependency_graph.push(dependency_path);
                             add_to_check(extends)?;
@@ -414,6 +417,17 @@ const TEXT_TYPES: [(Mime, Mime); 7] = [
     (mime::IMAGE_SVG, mime::IMAGE_SVG),
 ];
 
+fn cyclic_graph_error(dependency_graph: &[(Rc<Path>, Rc<Path>)]) -> Result<(), CompileError> {
+    Err(format!(
+        "cyclic dependency in graph {:#?}",
+        dependency_graph
+            .iter()
+            .map(|e| format!("{:#?} --> {:#?}", e.0, e.1))
+            .collect::<Vec<String>>()
+    )
+    .into())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/askama_derive/src/lib.rs b/askama_derive/src/lib.rs
index cc7e4ac..fd2b5c0 100644
--- a/askama_derive/src/lib.rs
+++ b/askama_derive/src/lib.rs
@@ -45,13 +45,10 @@ pub(crate) fn build_template(ast: &syn::DeriveInput) -> Result<String, CompileEr
 
     let mut contexts = HashMap::new();
     for (path, parsed) in &templates {
-        contexts.insert(
-            path.as_path(),
-            Context::new(input.config, path, parsed.nodes())?,
-        );
+        contexts.insert(path, Context::new(input.config, path, parsed.nodes())?);
     }
 
-    let ctx = &contexts[input.path.as_path()];
+    let ctx = &contexts[&input.path];
     let heritage = if !ctx.blocks.is_empty() || ctx.extends.is_some() {
         Some(Heritage::new(ctx, &contexts))
     } else {
@@ -59,11 +56,11 @@ pub(crate) fn build_template(ast: &syn::DeriveInput) -> Result<String, CompileEr
     };
 
     if input.print == Print::Ast || input.print == Print::All {
-        eprintln!("{:?}", templates[input.path.as_path()].nodes());
+        eprintln!("{:?}", templates[&input.path].nodes());
     }
 
     let code = Generator::new(&input, &contexts, heritage.as_ref(), MapChain::default())
-        .build(&contexts[input.path.as_path()])?;
+        .build(&contexts[&input.path])?;
     if input.print == Print::Code || input.print == Print::All {
         eprintln!("{code}");
     }
@@ -153,8 +150,6 @@ const BUILT_IN_FILTERS: &[&str] = &[
     "wordcount",
     // optional features, reserve the names anyway:
     "json",
-    "markdown",
-    "yaml",
 ];
 
 const CRATE: &str = if cfg!(feature = "with-actix-web") {
diff --git a/askama_parser/fuzz/fuzz_targets/fuzz_parser.rs b/askama_parser/fuzz/fuzz_targets/fuzz_parser.rs
index 1be1f94..7fbf1ad 100644
--- a/askama_parser/fuzz/fuzz_targets/fuzz_parser.rs
+++ b/askama_parser/fuzz/fuzz_targets/fuzz_parser.rs
@@ -6,6 +6,6 @@ use std::str;
 fuzz_target!(|data: &[u8]| {
     // fuzzed code goes here
     if let Ok(data) = str::from_utf8(data) {
-        if let Ok(_) = Ast::from_str(data, &Syntax::default()) {}
+        if let Ok(_) = Ast::from_str(data, None, &Syntax::default()) {}
     }
 });
diff --git a/askama_parser/src/lib.rs b/askama_parser/src/lib.rs
index 9d6c9ac..43b604d 100644
--- a/askama_parser/src/lib.rs
+++ b/askama_parser/src/lib.rs
@@ -3,6 +3,8 @@
 
 use std::borrow::Cow;
 use std::cell::Cell;
+use std::path::Path;
+use std::rc::Rc;
 use std::{fmt, str};
 
 use nom::branch::alt;
@@ -22,6 +24,8 @@ pub use node::Node;
 mod tests;
 
 mod _parsed {
+    use std::path::Path;
+    use std::rc::Rc;
     use std::{fmt, mem};
 
     use super::node::Node;
@@ -36,12 +40,18 @@ mod _parsed {
     }
 
     impl Parsed {
-        pub fn new(source: String, syntax: &Syntax<'_>) -> Result<Self, ParseError> {
+        /// If `file_path` is `None`, it means the `source` is an inline template. Therefore, if
+        /// a parsing error occurs, we won't display the path as it wouldn't be useful.
+        pub fn new(
+            source: String,
+            file_path: Option<Rc<Path>>,
+            syntax: &Syntax<'_>,
+        ) -> Result<Self, ParseError> {
             // Self-referential borrowing: `self` will keep the source alive as `String`,
             // internally we will transmute it to `&'static str` to satisfy the compiler.
             // However, we only expose the nodes with a lifetime limited to `self`.
             let src = unsafe { mem::transmute::<&str, &'static str>(source.as_str()) };
-            let ast = Ast::from_str(src, syntax)?;
+            let ast = Ast::from_str(src, file_path, syntax)?;
             Ok(Self { ast, source })
         }
 
@@ -74,7 +84,13 @@ pub struct Ast<'a> {
 }
 
 impl<'a> Ast<'a> {
-    pub fn from_str(src: &'a str, syntax: &Syntax<'_>) -> Result<Self, ParseError> {
+    /// If `file_path` is `None`, it means the `source` is an inline template. Therefore, if
+    /// a parsing error occurs, we won't display the path as it wouldn't be useful.
+    pub fn from_str(
+        src: &'a str,
+        file_path: Option<Rc<Path>>,
+        syntax: &Syntax<'_>,
+    ) -> Result<Self, ParseError> {
         let parse = |i: &'a str| Node::many(i, &State::new(syntax));
         let (input, message) = match terminated(parse, cut(eof))(src) {
             Ok(("", nodes)) => return Ok(Self { nodes }),
@@ -97,19 +113,27 @@ impl<'a> Ast<'a> {
         let (row, last_line) = source_before.lines().enumerate().last().unwrap_or_default();
         let column = last_line.chars().count();
 
-        let msg = format!(
-            "{}problems parsing template source at row {}, column {} near:\n{}",
-            if let Some(message) = message {
-                format!("{message}\n")
-            } else {
-                String::new()
-            },
-            row + 1,
-            column,
-            source_after,
-        );
+        let file_info = file_path.and_then(|file_path| {
+            let cwd = std::env::current_dir().ok()?;
+            Some((cwd, file_path))
+        });
+        let message = message
+            .map(|message| format!("{message}\n"))
+            .unwrap_or_default();
+        let error_msg = if let Some((cwd, file_path)) = file_info {
+            format!(
+                "{message}failed to parse template source\n  --> {path}:{row}:{column}\n{source_after}",
+                path = strip_common(&cwd, &file_path),
+                row = row + 1,
+            )
+        } else {
+            format!(
+                "{message}failed to parse template source at row {}, column {column} near:\n{source_after}",
+                row + 1,
+            )
+        };
 
-        Err(ParseError(msg))
+        Err(ParseError(error_msg))
     }
 
     pub fn nodes(&self) -> &[Node<'a>] {
@@ -487,3 +511,77 @@ fn filter(i: &str, level: Level) -> ParseResult<'_, (&str, Option<Vec<Expr<'_>>>
     ))(i)?;
     Ok((i, (fname, args)))
 }
+
+/// Returns the common parts of two paths.
+///
+/// The goal of this function is to reduce the path length based on the `base` argument
+/// (generally the path where the program is running into). For example:
+///
+/// ```text
+/// current dir: /a/b/c
+/// path:        /a/b/c/d/e.txt
+/// ```
+///
+/// `strip_common` will return `d/e.txt`.
+fn strip_common(base: &Path, path: &Path) -> String {
+    let path = match path.canonicalize() {
+        Ok(path) => path,
+        Err(_) => return path.display().to_string(),
+    };
+    let mut components_iter = path.components().peekable();
+
+    for current_path_component in base.components() {
+        let Some(path_component) = components_iter.peek() else {
+            return path.display().to_string();
+        };
+        if current_path_component != *path_component {
+            break;
+        }
+        components_iter.next();
+    }
+    let path_parts = components_iter
+        .map(|c| c.as_os_str().to_string_lossy())
+        .collect::<Vec<_>>();
+    if path_parts.is_empty() {
+        path.display().to_string()
+    } else {
+        path_parts.join("/")
+    }
+}
+
+#[cfg(test)]
+mod test {
+    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");
+
+        // We need actual existing paths for `canonicalize` to work, so let's do that.
+        let entry = cwd
+            .read_dir()
+            .expect("read_dir failed")
+            .filter_map(|f| f.ok())
+            .find(|f| f.path().is_file())
+            .expect("no entry");
+
+        // Since they have the complete path in common except for the folder entry name, it should
+        // return only the folder entry name.
+        assert_eq!(
+            strip_common(&cwd, &entry.path()),
+            entry.file_name().to_string_lossy()
+        );
+
+        #[cfg(target_os = "linux")]
+        {
+            // They both start with `/home` (normally), making the path empty, so in this case,
+            // the whole path should be returned.
+            assert_eq!(strip_common(&cwd, Path::new("/home")), "/home");
+        }
+
+        // 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");
+    }
+}
diff --git a/askama_parser/src/node.rs b/askama_parser/src/node.rs
index fdfc6e8..27f81e7 100644
--- a/askama_parser/src/node.rs
+++ b/askama_parser/src/node.rs
@@ -577,11 +577,12 @@ impl<'a> FilterBlock<'a> {
                 ws(identifier),
                 opt(|i| Expr::arguments(i, s.level.get(), false)),
                 many0(|i| filter(i, s.level.get())),
+                ws(|i| Ok((i, ()))),
                 opt(Whitespace::parse),
                 |i| s.tag_block_end(i),
             ))),
         ));
-        let (i, (pws1, _, (filter_name, params, extra_filters, nws1, _))) = start(i)?;
+        let (i, (pws1, _, (filter_name, params, extra_filters, _, nws1, _))) = start(i)?;
 
         let mut filters = Filter {
             name: filter_name,
diff --git a/askama_rocket/Cargo.toml b/askama_rocket/Cargo.toml
index 6d1f6c6..598c187 100644
--- a/askama_rocket/Cargo.toml
+++ b/askama_rocket/Cargo.toml
@@ -14,18 +14,16 @@ edition = "2021"
 rust-version = "1.65"
 
 [dependencies]
-askama = { version = "0.13", path = "../askama", default-features = false, features = ["with-rocket", "mime", "mime_guess"] }
+askama = { version = "0.13", path = "../askama", default-features = false, features = ["with-rocket"] }
 rocket = { version = "0.5", default-features = false }
 
 [dev-dependencies]
-futures-lite = "2.0.0"
+tokio = { version = "1.0", features = ["macros", "rt"] }
 
 [features]
 default = ["askama/default"]
 config = ["askama/config"]
 humansize = ["askama/humansize"]
-markdown = ["askama/markdown"]
 num-traits = ["askama/num-traits"]
 serde-json = ["askama/serde-json"]
-serde-yaml = ["askama/serde-yaml"]
 urlencode = ["askama/urlencode"]
diff --git a/askama_warp/Cargo.toml b/askama_warp/Cargo.toml
index b198d13..d9c9255 100644
--- a/askama_warp/Cargo.toml
+++ b/askama_warp/Cargo.toml
@@ -14,19 +14,16 @@ edition = "2021"
 rust-version = "1.65"
 
 [dependencies]
-askama = { version = "0.13", path = "../askama", default-features = false, features = ["with-warp", "mime", "mime_guess"] }
+askama = { version = "0.13", path = "../askama", default-features = false, features = ["with-warp"] }
 warp = { version = "0.3", default-features = false }
 
 [dev-dependencies]
-tokio-test = "0.4"
-tokio = { version = "1", features = ["macros"] }
+tokio = { version = "1.0", features = ["macros", "rt"] }
 
 [features]
 default = ["askama/default"]
 config = ["askama/config"]
 humansize = ["askama/humansize"]
-markdown = ["askama/markdown"]
 num-traits = ["askama/num-traits"]
 serde-json = ["askama/serde-json"]
-serde-yaml = ["askama/serde-yaml"]
 urlencode = ["askama/urlencode"]
diff --git a/book/src/filters.md b/book/src/filters.md
index a6f483a..9886528 100644
--- a/book/src/filters.md
+++ b/book/src/filters.md
@@ -14,8 +14,8 @@ is passed to the next.
 ```
 
 Askama has a collection of built-in filters, documented below, but can also include custom filters. 
-Additionally, the `json` and `yaml` filters are included in the built-in filters,
-but are disabled by default. Enable them with Cargo features (see below for more information).
+Additionally, the `json` filter is included in the built-in filters, but is disabled by default.
+Enable it with Cargo features (see below for more information).
 
 **Table of contents**
 
@@ -40,8 +40,6 @@ but are disabled by default. Enable them with Cargo features (see below for more
 
 * **[Optional / feature gated filters][#optional-filters]:**  
   [`json|tojson`][#json],
-  [`markdown`][#markdown],
-  [`yaml`][#yaml]
 
 * **[Custom filters][#custom-filters]**
 
@@ -392,41 +390,6 @@ Ugly: <script>var data = "{{data|json}}";</script>
 Ugly: <script>var data = '{{data|json|safe}}';</script>
 ```
 
-### `markdown`
-[#markdown]: #markdown
-
-Enabling the `markdown` feature will enable the use of the `markdown` filter.
-This will render a value using a [GitHub flavored CommonMark](https://docs.rs/comrak/0.14.*/comrak/) syntax.
-By default the extensions “autolink”, “strikethrough”, “tagfilter”, and “table” are enabled.
-Any raw HTML gets escaped.
-
-```jinja
-{{ "**<i>Hello</i>, world!**"|markdown }}
-```
-
-Output:
-
-```html
-<p><strong>&lt;i&gt;Hello&lt;/i&gt;, world!</strong></p>
-```
-
-You can change the default settings by supplying [custom options][ComrakRenderOptions], e.g. to enable unsafe raw HTML.
-You can find a usage example in our [unit tests][markdown-tests].
-
-[ComrakRenderOptions]: https://docs.rs/comrak/0.12.*/comrak/struct.ComrakRenderOptions.html
-[markdown-tests]: https://github.com/djc/askama/blob/5748c357d435b24848d1571df010d777859fede9/testing/tests/markdown.rs#L36-L75
-
-### `yaml`
-[#yaml]: #yaml
-
-Enabling the `serde-yaml` feature will enable the use of the `yaml` filter.
-This will output formatted YAML for any value that implements the required
-[`Serialize`](https://docs.rs/serde/1.*/serde/trait.Serialize.html) trait.
-
-```jinja
-{{ foo|yaml }}
-```
-
 ## Custom Filters
 [#custom-filters]: #custom-filters
 


Test patch:
diff --git a/askama_axum/tests/basic.rs b/askama_axum/tests/basic.rs
index 3099dac..bedf9dd 100644
--- a/askama_axum/tests/basic.rs
+++ b/askama_axum/tests/basic.rs
@@ -1,4 +1,4 @@
-use askama::Template;
+use askama_axum::Template;
 use axum::{
     body::Body,
     http::{Request, StatusCode},
diff --git a/askama_parser/src/tests.rs b/askama_parser/src/tests.rs
index 669e376..5b96161 100644
--- a/askama_parser/src/tests.rs
+++ b/askama_parser/src/tests.rs
@@ -20,14 +20,16 @@ fn test_ws_splitter() {
 #[test]
 #[should_panic]
 fn test_invalid_block() {
-    Ast::from_str("{% extend \"blah\" %}", &Syntax::default()).unwrap();
+    Ast::from_str("{% extend \"blah\" %}", None, &Syntax::default()).unwrap();
 }
 
 #[test]
 fn test_parse_filter() {
     let syntax = Syntax::default();
     assert_eq!(
-        Ast::from_str("{{ strvar|e }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ strvar|e }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -37,7 +39,7 @@ fn test_parse_filter() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ 2|abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ 2|abs }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -47,7 +49,7 @@ fn test_parse_filter() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ -2|abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ -2|abs }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -57,7 +59,9 @@ fn test_parse_filter() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1 - 2)|abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1 - 2)|abs }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -74,11 +78,11 @@ fn test_parse_filter() {
 fn test_parse_numbers() {
     let syntax = Syntax::default();
     assert_eq!(
-        Ast::from_str("{{ 2 }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ 2 }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::NumLit("2"),)],
     );
     assert_eq!(
-        Ast::from_str("{{ 2.5 }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ 2.5 }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::NumLit("2.5"),)],
     );
 }
@@ -88,16 +92,16 @@ fn test_parse_var() {
     let s = Syntax::default();
 
     assert_eq!(
-        Ast::from_str("{{ foo }}", &s).unwrap().nodes,
+        Ast::from_str("{{ foo }}", None, &s).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::Var("foo"))],
     );
     assert_eq!(
-        Ast::from_str("{{ foo_bar }}", &s).unwrap().nodes,
+        Ast::from_str("{{ foo_bar }}", None, &s).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::Var("foo_bar"))],
     );
 
     assert_eq!(
-        Ast::from_str("{{ none }}", &s).unwrap().nodes,
+        Ast::from_str("{{ none }}", None, &s).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::Var("none"))],
     );
 }
@@ -107,16 +111,16 @@ fn test_parse_const() {
     let s = Syntax::default();
 
     assert_eq!(
-        Ast::from_str("{{ FOO }}", &s).unwrap().nodes,
+        Ast::from_str("{{ FOO }}", None, &s).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::Path(vec!["FOO"]))],
     );
     assert_eq!(
-        Ast::from_str("{{ FOO_BAR }}", &s).unwrap().nodes,
+        Ast::from_str("{{ FOO_BAR }}", None, &s).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::Path(vec!["FOO_BAR"]))],
     );
 
     assert_eq!(
-        Ast::from_str("{{ NONE }}", &s).unwrap().nodes,
+        Ast::from_str("{{ NONE }}", None, &s).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::Path(vec!["NONE"]))],
     );
 }
@@ -126,11 +130,11 @@ fn test_parse_path() {
     let s = Syntax::default();
 
     assert_eq!(
-        Ast::from_str("{{ None }}", &s).unwrap().nodes,
+        Ast::from_str("{{ None }}", None, &s).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::Path(vec!["None"]))],
     );
     assert_eq!(
-        Ast::from_str("{{ Some(123) }}", &s).unwrap().nodes,
+        Ast::from_str("{{ Some(123) }}", None, &s).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Call(
@@ -141,14 +145,14 @@ fn test_parse_path() {
     );
 
     assert_eq!(
-        Ast::from_str("{{ Ok(123) }}", &s).unwrap().nodes,
+        Ast::from_str("{{ Ok(123) }}", None, &s).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Call(Box::new(Expr::Path(vec!["Ok"])), vec![Expr::NumLit("123")]),
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ Err(123) }}", &s).unwrap().nodes,
+        Ast::from_str("{{ Err(123) }}", None, &s).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Call(Box::new(Expr::Path(vec!["Err"])), vec![Expr::NumLit("123")]),
@@ -159,7 +163,7 @@ fn test_parse_path() {
 #[test]
 fn test_parse_var_call() {
     assert_eq!(
-        Ast::from_str("{{ function(\"123\", 3) }}", &Syntax::default())
+        Ast::from_str("{{ function(\"123\", 3) }}", None, &Syntax::default())
             .unwrap()
             .nodes,
         vec![Node::Expr(
@@ -177,14 +181,16 @@ fn test_parse_path_call() {
     let s = Syntax::default();
 
     assert_eq!(
-        Ast::from_str("{{ Option::None }}", &s).unwrap().nodes,
+        Ast::from_str("{{ Option::None }}", None, &s).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Path(vec!["Option", "None"])
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ Option::Some(123) }}", &s).unwrap().nodes,
+        Ast::from_str("{{ Option::Some(123) }}", None, &s)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Call(
@@ -195,7 +201,7 @@ fn test_parse_path_call() {
     );
 
     assert_eq!(
-        Ast::from_str("{{ self::function(\"123\", 3) }}", &s)
+        Ast::from_str("{{ self::function(\"123\", 3) }}", None, &s)
             .unwrap()
             .nodes,
         vec![Node::Expr(
@@ -212,7 +218,7 @@ fn test_parse_path_call() {
 fn test_parse_root_path() {
     let syntax = Syntax::default();
     assert_eq!(
-        Ast::from_str("{{ std::string::String::new() }}", &syntax)
+        Ast::from_str("{{ std::string::String::new() }}", None, &syntax)
             .unwrap()
             .nodes,
         vec![Node::Expr(
@@ -224,7 +230,7 @@ fn test_parse_root_path() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ ::std::string::String::new() }}", &syntax)
+        Ast::from_str("{{ ::std::string::String::new() }}", None, &syntax)
             .unwrap()
             .nodes,
         vec![Node::Expr(
@@ -241,14 +247,16 @@ fn test_parse_root_path() {
 fn test_rust_macro() {
     let syntax = Syntax::default();
     assert_eq!(
-        Ast::from_str("{{ vec!(1, 2, 3) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ vec!(1, 2, 3) }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::RustMacro(vec!["vec"], "1, 2, 3",),
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ alloc::vec!(1, 2, 3) }}", &syntax)
+        Ast::from_str("{{ alloc::vec!(1, 2, 3) }}", None, &syntax)
             .unwrap()
             .nodes,
         vec![Node::Expr(
@@ -257,30 +265,30 @@ fn test_rust_macro() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{a!()}}", &syntax).unwrap().nodes,
+        Ast::from_str("{{a!()}}", None, &syntax).unwrap().nodes,
         [Node::Expr(Ws(None, None), Expr::RustMacro(vec!["a"], ""))],
     );
     assert_eq!(
-        Ast::from_str("{{a !()}}", &syntax).unwrap().nodes,
+        Ast::from_str("{{a !()}}", None, &syntax).unwrap().nodes,
         [Node::Expr(Ws(None, None), Expr::RustMacro(vec!["a"], ""))],
     );
     assert_eq!(
-        Ast::from_str("{{a! ()}}", &syntax).unwrap().nodes,
+        Ast::from_str("{{a! ()}}", None, &syntax).unwrap().nodes,
         [Node::Expr(Ws(None, None), Expr::RustMacro(vec!["a"], ""))],
     );
     assert_eq!(
-        Ast::from_str("{{a ! ()}}", &syntax).unwrap().nodes,
+        Ast::from_str("{{a ! ()}}", None, &syntax).unwrap().nodes,
         [Node::Expr(Ws(None, None), Expr::RustMacro(vec!["a"], ""))],
     );
     assert_eq!(
-        Ast::from_str("{{A!()}}", &syntax).unwrap().nodes,
+        Ast::from_str("{{A!()}}", None, &syntax).unwrap().nodes,
         [Node::Expr(Ws(None, None), Expr::RustMacro(vec!["A"], ""),)],
     );
     assert_eq!(
-        &*Ast::from_str("{{a.b.c!( hello )}}", &syntax)
+        &*Ast::from_str("{{a.b.c!( hello )}}", None, &syntax)
             .unwrap_err()
             .to_string(),
-        "problems parsing template source at row 1, column 7 near:\n\"!( hello )}}\"",
+        "failed to parse template source at row 1, column 7 near:\n\"!( hello )}}\"",
     );
 }
 
@@ -292,14 +300,16 @@ fn change_delimiters_parse_filter() {
         ..Syntax::default()
     };
 
-    Ast::from_str("{= strvar|e =}", &syntax).unwrap();
+    Ast::from_str("{= strvar|e =}", None, &syntax).unwrap();
 }
 
 #[test]
 fn test_precedence() {
     let syntax = Syntax::default();
     assert_eq!(
-        Ast::from_str("{{ a + b == c }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a + b == c }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -310,7 +320,7 @@ fn test_precedence() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ a + b * c - d / e }}", &syntax)
+        Ast::from_str("{{ a + b * c - d / e }}", None, &syntax)
             .unwrap()
             .nodes,
         vec![Node::Expr(
@@ -328,7 +338,7 @@ fn test_precedence() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ a * (b + c) / -d }}", &syntax)
+        Ast::from_str("{{ a * (b + c) / -d }}", None, &syntax)
             .unwrap()
             .nodes,
         vec![Node::Expr(
@@ -349,7 +359,7 @@ fn test_precedence() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ a || b && c || d && e }}", &syntax)
+        Ast::from_str("{{ a || b && c || d && e }}", None, &syntax)
             .unwrap()
             .nodes,
         vec![Node::Expr(
@@ -372,7 +382,9 @@ fn test_precedence() {
 fn test_associativity() {
     let syntax = Syntax::default();
     assert_eq!(
-        Ast::from_str("{{ a + b + c }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a + b + c }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -383,7 +395,9 @@ fn test_associativity() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ a * b * c }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a * b * c }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -394,7 +408,9 @@ fn test_associativity() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ a && b && c }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a && b && c }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -405,7 +421,9 @@ fn test_associativity() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ a + b - c + d }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a + b - c + d }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -421,7 +439,7 @@ fn test_associativity() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ a == b != c > d > e == f }}", &syntax)
+        Ast::from_str("{{ a == b != c > d > e == f }}", None, &syntax)
             .unwrap()
             .nodes,
         vec![Node::Expr(
@@ -454,7 +472,7 @@ fn test_associativity() {
 fn test_odd_calls() {
     let syntax = Syntax::default();
     assert_eq!(
-        Ast::from_str("{{ a[b](c) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a[b](c) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Call(
@@ -467,7 +485,9 @@ fn test_odd_calls() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (a + b)(c) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (a + b)(c) }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Call(
@@ -481,7 +501,9 @@ fn test_odd_calls() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ a + b(c) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a + b(c) }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -492,7 +514,7 @@ fn test_odd_calls() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (-a)(b) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (-a)(b) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Call(
@@ -505,7 +527,7 @@ fn test_odd_calls() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ -a(b) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ -a(b) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Unary(
@@ -515,7 +537,7 @@ fn test_odd_calls() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ a(b)|c }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a(b)|c }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -525,7 +547,7 @@ fn test_odd_calls() {
         )]
     );
     assert_eq!(
-        Ast::from_str("{{ a(b)| c }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a(b)| c }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -535,7 +557,7 @@ fn test_odd_calls() {
         )]
     );
     assert_eq!(
-        Ast::from_str("{{ a(b) |c }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ a(b) |c }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -551,7 +573,7 @@ fn test_odd_calls() {
 fn test_parse_comments() {
     fn one_comment_ws(source: &str, ws: Ws) {
         let s = &Syntax::default();
-        let mut nodes = Ast::from_str(source, s).unwrap().nodes;
+        let mut nodes = Ast::from_str(source, None, s).unwrap().nodes;
         assert_eq!(nodes.len(), 1, "expected to parse one node");
         match nodes.pop().unwrap() {
             Node::Comment(comment) => assert_eq!(comment.ws, ws),
@@ -610,60 +632,62 @@ fn test_parse_comments() {
 fn test_parse_tuple() {
     let syntax = Syntax::default();
     assert_eq!(
-        Ast::from_str("{{ () }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ () }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::Tuple(vec![]),)],
     );
     assert_eq!(
-        Ast::from_str("{{ (1) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Group(Box::new(Expr::NumLit("1"))),
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1,) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1,) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Tuple(vec![Expr::NumLit("1")]),
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1, ) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1, ) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Tuple(vec![Expr::NumLit("1")]),
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1 ,) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1 ,) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Tuple(vec![Expr::NumLit("1")]),
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1 , ) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1 , ) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Tuple(vec![Expr::NumLit("1")]),
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1, 2) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1, 2) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Tuple(vec![Expr::NumLit("1"), Expr::NumLit("2")]),
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1, 2,) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1, 2,) }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Tuple(vec![Expr::NumLit("1"), Expr::NumLit("2")]),
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1, 2, 3) }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1, 2, 3) }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Tuple(vec![
@@ -674,7 +698,7 @@ fn test_parse_tuple() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ ()|abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ ()|abs }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -684,7 +708,9 @@ fn test_parse_tuple() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ () | abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ () | abs }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -695,7 +721,7 @@ fn test_parse_tuple() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1)|abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1)|abs }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -705,7 +731,9 @@ fn test_parse_tuple() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1) | abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1) | abs }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -716,7 +744,9 @@ fn test_parse_tuple() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1,)|abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1,)|abs }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -726,7 +756,9 @@ fn test_parse_tuple() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1,) | abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1,) | abs }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -737,7 +769,9 @@ fn test_parse_tuple() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1, 2)|abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1, 2)|abs }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -747,7 +781,9 @@ fn test_parse_tuple() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ (1, 2) | abs }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ (1, 2) | abs }}", None, &syntax)
+            .unwrap()
+            .nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -762,10 +798,10 @@ fn test_parse_tuple() {
 #[test]
 fn test_missing_space_after_kw() {
     let syntax = Syntax::default();
-    let err = Ast::from_str("{%leta=b%}", &syntax).unwrap_err();
+    let err = Ast::from_str("{%leta=b%}", None, &syntax).unwrap_err();
     assert!(matches!(
         &*err.to_string(),
-        "problems parsing template source at row 1, column 0 near:\n\"{%leta=b%}\"",
+        "failed to parse template source at row 1, column 0 near:\n\"{%leta=b%}\"",
     ));
 }
 
@@ -773,60 +809,60 @@ fn test_missing_space_after_kw() {
 fn test_parse_array() {
     let syntax = Syntax::default();
     assert_eq!(
-        Ast::from_str("{{ [] }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ [] }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(Ws(None, None), Expr::Array(vec![]))],
     );
     assert_eq!(
-        Ast::from_str("{{ [1] }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ [1] }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Array(vec![Expr::NumLit("1")])
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ [ 1] }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ [ 1] }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Array(vec![Expr::NumLit("1")])
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ [1 ] }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ [1 ] }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Array(vec![Expr::NumLit("1")])
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ [1,2] }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ [1,2] }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Array(vec![Expr::NumLit("1"), Expr::NumLit("2")])
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ [1 ,2] }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ [1 ,2] }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Array(vec![Expr::NumLit("1"), Expr::NumLit("2")])
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ [1, 2] }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ [1, 2] }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Array(vec![Expr::NumLit("1"), Expr::NumLit("2")])
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ [1,2 ] }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ [1,2 ] }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Array(vec![Expr::NumLit("1"), Expr::NumLit("2")])
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ []|foo }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ []|foo }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -836,7 +872,7 @@ fn test_parse_array() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ []| foo }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ []| foo }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::Filter(Filter {
@@ -846,7 +882,7 @@ fn test_parse_array() {
         )],
     );
     assert_eq!(
-        Ast::from_str("{{ [] |foo }}", &syntax).unwrap().nodes,
+        Ast::from_str("{{ [] |foo }}", None, &syntax).unwrap().nodes,
         vec![Node::Expr(
             Ws(None, None),
             Expr::BinOp(
@@ -862,23 +898,23 @@ fn test_parse_array() {
 fn fuzzed_unicode_slice() {
     let d = "{eeuuu{b&{!!&{!!11{{
             0!(!1q҄א!)!!!!!!n!";
-    assert!(Ast::from_str(d, &Syntax::default()).is_err());
+    assert!(Ast::from_str(d, None, &Syntax::default()).is_err());
 }
 
 #[test]
 fn fuzzed_macro_no_end() {
     let s = "{%macro super%}{%endmacro";
-    assert!(Ast::from_str(s, &Syntax::default()).is_err());
+    assert!(Ast::from_str(s, None, &Syntax::default()).is_err());
 }
 
 #[test]
 fn fuzzed_target_recursion() {
     const TEMPLATE: &str = include_str!("../tests/target-recursion.txt");
-    assert!(Ast::from_str(TEMPLATE, &Syntax::default()).is_err());
+    assert!(Ast::from_str(TEMPLATE, None, &Syntax::default()).is_err());
 }
 
 #[test]
 fn fuzzed_unary_recursion() {
     const TEMPLATE: &str = include_str!("../tests/unary-recursion.txt");
-    assert!(Ast::from_str(TEMPLATE, &Syntax::default()).is_err());
+    assert!(Ast::from_str(TEMPLATE, None, &Syntax::default()).is_err());
 }
diff --git a/askama_rocket/tests/basic.rs b/askama_rocket/tests/basic.rs
index 1ded705..9ae026e 100644
--- a/askama_rocket/tests/basic.rs
+++ b/askama_rocket/tests/basic.rs
@@ -1,6 +1,4 @@
-use askama::Template;
-
-use futures_lite::future::block_on;
+use askama_rocket::Template;
 use rocket::http::{ContentType, Status};
 use rocket::local::asynchronous::Client;
 use rocket::Responder;
@@ -21,18 +19,16 @@ fn hello() -> HelloTemplate<'static> {
     HelloTemplate { name: "world" }
 }
 
-#[test]
-fn test_rocket() {
-    block_on(async {
-        let rocket = rocket::build()
-            .mount("/", rocket::routes![hello])
-            .ignite()
-            .await
-            .unwrap();
-        let client = Client::untracked(rocket).await.unwrap();
-        let rsp = client.get("/").dispatch().await;
-        assert_eq!(rsp.status(), Status::Ok);
-        assert_eq!(rsp.content_type(), Some(ContentType::HTML));
-        assert_eq!(rsp.into_string().await.as_deref(), Some("Hello, world!"));
-    });
+#[tokio::test]
+async fn test_rocket() {
+    let rocket = rocket::build()
+        .mount("/", rocket::routes![hello])
+        .ignite()
+        .await
+        .unwrap();
+    let client = Client::untracked(rocket).await.unwrap();
+    let rsp = client.get("/").dispatch().await;
+    assert_eq!(rsp.status(), Status::Ok);
+    assert_eq!(rsp.content_type(), Some(ContentType::HTML));
+    assert_eq!(rsp.into_string().await.as_deref(), Some("Hello, world!"));
 }
diff --git a/askama_warp/tests/warp.rs b/askama_warp/tests/warp.rs
index 69a5f23..83ed0f8 100644
--- a/askama_warp/tests/warp.rs
+++ b/askama_warp/tests/warp.rs
@@ -1,4 +1,4 @@
-use askama::Template;
+use askama_warp::Template;
 use warp::Filter;
 
 #[derive(Template)]
diff --git a/testing/Cargo.toml b/testing/Cargo.toml
index 9d6c673..1e2efdc 100644
--- a/testing/Cargo.toml
+++ b/testing/Cargo.toml
@@ -8,13 +8,11 @@ rust-version = "1.65"
 publish = false
 
 [features]
-default = ["serde-json", "markdown"]
+default = ["serde-json"]
 serde-json = ["serde_json", "askama/serde-json"]
-markdown = ["comrak", "askama/markdown"]
 
 [dependencies]
 askama = { path = "../askama", version = "0.13" }
-comrak = { version = "0.21", default-features = false, optional = true }
 phf = { version = "0.11", features = ["macros" ]}
 serde_json = { version = "1.0", optional = true }
 
diff --git a/testing/templates/cycle1.html b/testing/templates/cycle1.html
new file mode 100644
index 0000000..f48ce0c
--- /dev/null
+++ b/testing/templates/cycle1.html
@@ -0,0 +1,1 @@
+{% extends "cycle1.html" %}
diff --git a/testing/templates/cycle2.html b/testing/templates/cycle2.html
new file mode 100644
index 0000000..f48ce0c
--- /dev/null
+++ b/testing/templates/cycle2.html
@@ -0,0 +1,1 @@
+{% extends "cycle1.html" %}
diff --git a/testing/templates/include_invalid_syntax.html b/testing/templates/include_invalid_syntax.html
new file mode 100644
index 0000000..a0d25c1
--- /dev/null
+++ b/testing/templates/include_invalid_syntax.html
@@ -0,0 +1,1 @@
+{% extends "invalid_syntax.html" %}
diff --git a/testing/templates/invalid_syntax.html b/testing/templates/invalid_syntax.html
new file mode 100644
index 0000000..3226bb8
--- /dev/null
+++ b/testing/templates/invalid_syntax.html
@@ -0,0 +1,1 @@
+{% let 12 = 0 }
diff --git a/testing/tests/filter_block.rs b/testing/tests/filter_block.rs
index 55f7e1b..a053fd8 100644
--- a/testing/tests/filter_block.rs
+++ b/testing/tests/filter_block.rs
@@ -147,3 +147,53 @@ fn filter_block_chaining() {
     let template = F { v: "pIKA" };
     assert_eq!(template.render().unwrap(), "Hello\n  pika");
 }
+
+// This test checks that the filter chaining is working as expected when ending
+// followed by whitespace character(s).
+#[derive(Template)]
+#[template(
+    source = r#"{% filter lower|indent(2) -%}
+HELLO
+{{v}}
+{%- endfilter %}
+
+{% filter lower|indent(2)   -%}
+HELLO
+{{v}}
+{%- endfilter %}
+
+{% filter lower|indent(2) %}
+HELLO
+{{v}}
+{%- endfilter %}
+
+{% filter lower|indent(2)   %}
+HELLO
+{{v}}
+{%- endfilter %}"#,
+    ext = "txt"
+)]
+struct G {
+    v: &'static str,
+}
+
+#[test]
+fn filter_block_chaining_paren_followed_by_whitespace() {
+    let template = G { v: "pIKA" };
+    assert_eq!(
+        template.render().unwrap(),
+        r#"hello
+  pika
+
+hello
+  pika
+
+
+  hello
+  pika
+
+
+  hello
+  pika"#
+    );
+}
diff --git a/testing/tests/markdown.rs b/testing/tests/markdown.rs
deleted file mode 100644
index 04c9ec9..0000000
--- a/testing/tests/markdown.rs
+++ /dev/null
@@ -1,90 +0,0 @@
-#![cfg(feature = "markdown")]
-
-use askama::Template;
-use comrak::Options;
-
-#[derive(Template)]
-#[template(source = "{{before}}{{content|markdown}}{{after}}", ext = "html")]
-struct MarkdownTemplate<'a> {
-    before: &'a str,
-    after: &'a str,
-    content: &'a str,
-}
-
-#[test]
-fn test_markdown() {
-    let s = MarkdownTemplate {
-        before: "before",
-        after: "after",
-        content: "* 1\n* <script>alert('Lol, hacked!')</script>\n* 3",
-    };
-    assert_eq!(
-        s.render().unwrap(),
-        "\
-before\
-<ul>\n\
-<li>1</li>\n\
-<li>\n\
-&lt;script&gt;alert('Lol, hacked!')&lt;/script&gt;\n\
-</li>\n\
-<li>3</li>\n\
-</ul>\n\
-after",
-    );
-}
-
-#[derive(Template)]
-#[template(
-    source = "{{before}}{{content|markdown(options)}}{{after}}",
-    ext = "html"
-)]
-struct MarkdownWithOptionsTemplate<'a> {
-    before: &'a str,
-    after: &'a str,
-    content: &'a str,
-    options: &'a Options,
-}
-
-#[test]
-fn test_markdown_with_options() {
-    let mut options = Options::default();
-    options.render.unsafe_ = true;
-
-    let s = MarkdownWithOptionsTemplate {
-        before: "before",
-        after: "after",
-        content: "* 1\n* <script>alert('Lol, hacked!')</script>\n* 3",
-        options: &options,
-    };
-    assert_eq!(
-        s.render().unwrap(),
-        "\
-before\
-<ul>\n\
-<li>1</li>\n\
-<li>\n\
-<script>alert('Lol, hacked!')</script>\n\
-</li>\n\
-<li>3</li>\n\
-</ul>\n\
-after",
-    );
-}
-
-#[derive(Template)]
-#[template(source = "{{content|markdown}}", ext = "html")]
-struct MarkdownStringTemplate {
-    content: String,
-}
-
-// Tests if the markdown filter accepts String
-#[test]
-fn test_markdown_owned_string() {
-    let template = MarkdownStringTemplate {
-        content: "The markdown filter _indeed_ works with __String__".into(),
-    };
-    assert_eq!(
-        template.render().unwrap(),
-        "<p>The markdown filter <em>indeed</em> works with <strong>String</strong></p>\n"
-    )
-}
diff --git a/testing/tests/size_hint.rs b/testing/tests/size_hint.rs
index a9f8428..26e5c9f 100644
--- a/testing/tests/size_hint.rs
+++ b/testing/tests/size_hint.rs
@@ -3,6 +3,7 @@ use askama::Template;
 macro_rules! test_size {
     ($source:literal, $expected:expr) => {{
         #[derive(Template)]
+        #[allow(dead_code)]
         #[template(source = $source, ext = "txt")]
         struct T(bool);
 
diff --git a/testing/tests/ui.rs b/testing/tests/ui.rs
index 33848b2..5adb5c9 100644
--- a/testing/tests/ui.rs
+++ b/testing/tests/ui.rs
@@ -1,7 +1,11 @@
+#![cfg(not(windows))]
+
+use std::os::unix::fs::symlink;
+use std::path::Path;
 use trybuild::TestCases;
 use version_check as rustc;
 
-#[cfg_attr(not(windows), test)]
+#[test]
 fn ui() {
     let t = TestCases::new();
     t.compile_fail("tests/ui/*.rs");
@@ -21,4 +25,28 @@ fn ui() {
     if rustc::is_max_version("1.53").unwrap() {
         t.compile_fail("tests/ui/before_1.54/*.rs");
     }
+
+    // To be able to use existing templates, we create a link to the `templates` folder.
+    let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
+        Ok(manifest_dir) => manifest_dir,
+        Err(_) => panic!("you need to run tests with `cargo`"),
+    };
+    let target = Path::new(&manifest_dir).join("../target/tests/trybuild/askama_testing");
+    if !target.exists() {
+        if let Err(err) = std::fs::create_dir_all(&target) {
+            panic!("failed to create folder `{}`: {err:?}", target.display());
+        }
+    }
+    let target = target.canonicalize().unwrap().join("templates");
+    if target.exists() {
+        return;
+    }
+    let original = Path::new(&manifest_dir).join("templates");
+    if symlink(&original, &target).is_err() {
+        panic!(
+            "failed to create to create link on `{}` as `{}`",
+            original.display(),
+            target.display()
+        );
+    }
 }
diff --git a/testing/tests/ui/cycle.rs b/testing/tests/ui/cycle.rs
new file mode 100644
index 0000000..b6f7f61
--- /dev/null
+++ b/testing/tests/ui/cycle.rs
@@ -0,0 +1,8 @@
+use askama::Template;
+
+#[derive(Template)]
+#[template(path = "cycle2.html")]
+struct Cycle;
+
+fn main() {
+}
diff --git a/testing/tests/ui/cycle.stderr b/testing/tests/ui/cycle.stderr
new file mode 100644
index 0000000..ea8c9e2
--- /dev/null
+++ b/testing/tests/ui/cycle.stderr
@@ -0,0 +1,10 @@
+error: cyclic dependency in graph [
+           "\"$WORKSPACE/target/tests/trybuild/askama_testing/templates/cycle2.html/" --> \"$WORKSPACE/target/tests/trybuild/askama_testing/templates/cycle1.html/"",
+           "\"$WORKSPACE/target/tests/trybuild/askama_testing/templates/cycle1.html/" --> \"$WORKSPACE/target/tests/trybuild/askama_testing/templates/cycle1.html/"",
+       ]
+ --> tests/ui/cycle.rs:3:10
+  |
+3 | #[derive(Template)]
+  |          ^^^^^^^^
+  |
+  = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
diff --git a/testing/tests/ui/cycle2.rs b/testing/tests/ui/cycle2.rs
new file mode 100644
index 0000000..9fbae96
--- /dev/null
+++ b/testing/tests/ui/cycle2.rs
@@ -0,0 +1,8 @@
+use askama::Template;
+
+#[derive(Template)]
+#[template(path = "cycle1.html")]
+struct Cycle;
+
+fn main() {
+}
diff --git a/testing/tests/ui/cycle2.stderr b/testing/tests/ui/cycle2.stderr
new file mode 100644
index 0000000..682f850
--- /dev/null
+++ b/testing/tests/ui/cycle2.stderr
@@ -0,0 +1,9 @@
+error: cyclic dependency in graph [
+           "\"$WORKSPACE/target/tests/trybuild/askama_testing/templates/cycle1.html/" --> \"$WORKSPACE/target/tests/trybuild/askama_testing/templates/cycle1.html/"",
+       ]
+ --> tests/ui/cycle2.rs:3:10
+  |
+3 | #[derive(Template)]
+  |          ^^^^^^^^
+  |
+  = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
diff --git a/testing/tests/ui/elif.stderr b/testing/tests/ui/elif.stderr
index 0d5de47..593e5c4 100644
--- a/testing/tests/ui/elif.stderr
+++ b/testing/tests/ui/elif.stderr
@@ -1,5 +1,5 @@
 error: unknown `elif` keyword; did you mean `else if`?
-       problems parsing template source at row 1, column 16 near:
+       failed to parse template source at row 1, column 16 near:
        "elif false %}{% endif %}"
  --> tests/ui/elif.rs:3:10
   |
diff --git a/testing/tests/ui/error_file_path.rs b/testing/tests/ui/error_file_path.rs
new file mode 100644
index 0000000..ec38053
--- /dev/null
+++ b/testing/tests/ui/error_file_path.rs
@@ -0,0 +1,16 @@
+use askama::Template;
+
+#[derive(Template)]
+#[template(path = "invalid_syntax.html")]
+struct A;
+
+#[derive(Template)]
+#[template(path = "include_invalid_syntax.html")]
+struct B;
+
+#[derive(Template)]
+#[template(source = r#"{% extends "include_invalid_syntax.html" %}"#, ext = "txt")]
+struct C;
+
+fn main() {
+}
diff --git a/testing/tests/ui/error_file_path.stderr b/testing/tests/ui/error_file_path.stderr
new file mode 100644
index 0000000..58b449a
--- /dev/null
+++ b/testing/tests/ui/error_file_path.stderr
@@ -0,0 +1,29 @@
+error: failed to parse template source
+ --> testing/templates/invalid_syntax.html:1:14
+       "}"
+ --> tests/ui/error_file_path.rs:3:10
+  |
+3 | #[derive(Template)]
+  |          ^^^^^^^^
+  |
+  = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error: failed to parse template source
+ --> testing/templates/invalid_syntax.html:1:14
+       "}"
+ --> tests/ui/error_file_path.rs:7:10
+  |
+7 | #[derive(Template)]
+  |          ^^^^^^^^
+  |
+  = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error: failed to parse template source
+ --> testing/templates/invalid_syntax.html:1:14
+       "}"
+  --> tests/ui/error_file_path.rs:11:10
+   |
+11 | #[derive(Template)]
+   |          ^^^^^^^^
+   |
+   = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
diff --git a/testing/tests/ui/excessive_nesting.stderr b/testing/tests/ui/excessive_nesting.stderr
index f8f48ec..ed01de9 100644
--- a/testing/tests/ui/excessive_nesting.stderr
+++ b/testing/tests/ui/excessive_nesting.stderr
@@ -1,4 +1,4 @@
-error: problems parsing template source at row 14, column 34 near:
+error: failed to parse template source at row 14, column 34 near:
        "%}{%if 1%}{%if 1%}{%if 1%}{%if 1%}{%if 1"...
  --> tests/ui/excessive_nesting.rs:3:10
   |
diff --git a/testing/tests/ui/extends.stderr b/testing/tests/ui/extends.stderr
index 346246d..a209811 100644
--- a/testing/tests/ui/extends.stderr
+++ b/testing/tests/ui/extends.stderr
@@ -1,5 +1,5 @@
 error: whitespace control is not allowed on `extends`
-       problems parsing template source at row 1, column 2 near:
+       failed to parse template source at row 1, column 2 near:
        "- extends \"whatever.html\" %}"
  --> tests/ui/extends.rs:3:10
   |
@@ -9,7 +9,7 @@ error: whitespace control is not allowed on `extends`
   = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
 
 error: whitespace control is not allowed on `extends`
-       problems parsing template source at row 1, column 2 near:
+       failed to parse template source at row 1, column 2 near:
        " extends \"whatever.html\" -%}"
   --> tests/ui/extends.rs:10:10
    |
diff --git a/testing/tests/ui/filter_block_ws.rs b/testing/tests/ui/filter_block_ws.rs
new file mode 100644
index 0000000..3f96be1
--- /dev/null
+++ b/testing/tests/ui/filter_block_ws.rs
@@ -0,0 +1,12 @@
+use askama::Template;
+
+#[derive(Template)]
+#[template(source = "{% filter lower|indent(2) - %}
+HELLO
+{{v}}
+{%- endfilter %}", ext = "html")]
+struct A;
+
+fn main() {
+    A.render().unwrap();
+}
diff --git a/testing/tests/ui/filter_block_ws.stderr b/testing/tests/ui/filter_block_ws.stderr
new file mode 100644
index 0000000..de06272
--- /dev/null
+++ b/testing/tests/ui/filter_block_ws.stderr
@@ -0,0 +1,21 @@
+error: problems parsing template source at row 1, column 27 near:
+       " %}\nHELLO\n{{v}}\n{%- endfilter %}"
+ --> tests/ui/filter_block_ws.rs:3:10
+  |
+3 | #[derive(Template)]
+  |          ^^^^^^^^
+  |
+  = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error[E0599]: no method named `render` found for struct `A` in the current scope
+  --> tests/ui/filter_block_ws.rs:11:7
+   |
+8  | struct A;
+   | -------- method `render` not found for this struct
+...
+11 |     A.render().unwrap();
+   |       ^^^^^^ method not found in `A`
+   |
+   = help: items from traits can only be used if the trait is implemented and in scope
+   = note: the following trait defines an item `render`, perhaps you need to implement it:
+           candidate #1: `Template`
diff --git a/testing/tests/ui/macro_named_argument.stderr b/testing/tests/ui/macro_named_argument.stderr
index b346f2d..f855e6c 100644
--- a/testing/tests/ui/macro_named_argument.stderr
+++ b/testing/tests/ui/macro_named_argument.stderr
@@ -7,7 +7,7 @@ error: no argument named `param3` in macro "thrice"
   = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
 
 error: named argument `param1` was passed more than once
-       problems parsing template source at row 5, column 15 near:
+       failed to parse template source at row 5, column 15 near:
        "(param1=2, param1=3) -%}"
   --> tests/ui/macro_named_argument.rs:11:10
    |
@@ -16,7 +16,7 @@ error: named argument `param1` was passed more than once
    |
    = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
 
-error: problems parsing template source at row 5, column 29 near:
+error: failed to parse template source at row 5, column 29 near:
        "| filter(param1=12) -%}"
   --> tests/ui/macro_named_argument.rs:20:10
    |
@@ -26,7 +26,7 @@ error: problems parsing template source at row 5, column 29 near:
    = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
 
 error: named arguments must always be passed last
-       problems parsing template source at row 4, column 15 near:
+       failed to parse template source at row 4, column 15 near:
        "(param1=2, 3) -%}"
   --> tests/ui/macro_named_argument.rs:29:10
    |
diff --git a/testing/tests/ui/match_with_extra.stderr b/testing/tests/ui/match_with_extra.stderr
index 8f515bd..841ee2e 100644
--- a/testing/tests/ui/match_with_extra.stderr
+++ b/testing/tests/ui/match_with_extra.stderr
@@ -1,4 +1,4 @@
-error: problems parsing template source at row 3, column 4 near:
+error: failed to parse template source at row 3, column 4 near:
        "// Help, I forgot how to write comments!"...
  --> tests/ui/match_with_extra.rs:3:10
   |
diff --git a/testing/tests/ui/name_mismatch_endblock.stderr b/testing/tests/ui/name_mismatch_endblock.stderr
index 958c413..8894e13 100644
--- a/testing/tests/ui/name_mismatch_endblock.stderr
+++ b/testing/tests/ui/name_mismatch_endblock.stderr
@@ -1,5 +1,5 @@
 error: expected name `foo` in `endblock` tag, found `not_foo`
-       problems parsing template source at row 1, column 27 near:
+       failed to parse template source at row 1, column 27 near:
        "not_foo %}"
  --> tests/ui/name_mismatch_endblock.rs:3:10
   |
diff --git a/testing/tests/ui/name_mismatch_endmacro.stderr b/testing/tests/ui/name_mismatch_endmacro.stderr
index 74101a4..14e7d0a 100644
--- a/testing/tests/ui/name_mismatch_endmacro.stderr
+++ b/testing/tests/ui/name_mismatch_endmacro.stderr
@@ -1,5 +1,5 @@
 error: expected name `foo` in `endmacro` tag, found `not_foo`
-       problems parsing template source at row 1, column 41 near:
+       failed to parse template source at row 1, column 41 near:
        "not_foo %}"
  --> tests/ui/name_mismatch_endmacro.rs:3:10
   |
diff --git a/testing/tests/ui/since_1.58/break_outside_of_loop.stderr b/testing/tests/ui/since_1.58/break_outside_of_loop.stderr
index 8f6ba68..b7a74d4 100644
--- a/testing/tests/ui/since_1.58/break_outside_of_loop.stderr
+++ b/testing/tests/ui/since_1.58/break_outside_of_loop.stderr
@@ -1,4 +1,4 @@
-error: problems parsing template source at row 1, column 9 near:
+error: failed to parse template source at row 1, column 9 near:
        "break%}, have a parsing error!"
  --> tests/ui/since_1.58/break_outside_of_loop.rs:3:10
   |
diff --git a/testing/tests/ui/since_1.58/typo_in_keyword.stderr b/testing/tests/ui/since_1.58/typo_in_keyword.stderr
index 92c638d..a0cb99b 100644
--- a/testing/tests/ui/since_1.58/typo_in_keyword.stderr
+++ b/testing/tests/ui/since_1.58/typo_in_keyword.stderr
@@ -1,4 +1,4 @@
-error: problems parsing template source at row 1, column 26 near:
+error: failed to parse template source at row 1, column 26 near:
        "endfo%}\n12345678901234567890123456789012"...
  --> tests/ui/since_1.58/typo_in_keyword.rs:3:10
   |