Repository: djc/askama
Pull request #622 (2022-02-01 01:58:18)

Add markdown filter

Kijewski: "expected struct `std::time::Duration`, found `u64`" seems unrelated. `actix-test` was updated 2h ago. I guess you should not assume a 0.1-beta version to be stable. :)
vallentin: The dependabot pr #623 also failed, so probably a change to actix-test, yeah :)
Kijewski: I replaced pulldown-cmark by comrak. Unsafe HTML is now sanitized by default, but the user may opt-out to allow unsafe HTML.
Kijewski: I restructured the PR a bit, and I think it's quite pretty now. :)

Patch:
diff --git a/askama/Cargo.toml b/askama/Cargo.toml
index 281e232..3dc305e 100644
--- a/askama/Cargo.toml
+++ b/askama/Cargo.toml
@@ -19,6 +19,7 @@ maintenance = { status = "actively-developed" }
 default = ["config", "humansize", "num-traits", "urlencode"]
 config = ["askama_derive/config", "askama_shared/config"]
 humansize = ["askama_shared/humansize"]
+markdown = ["askama_shared/markdown"]
 urlencode = ["askama_shared/percent-encoding"]
 serde-json = ["askama_derive/json", "askama_shared/json"]
 serde-yaml = ["askama_derive/yaml", "askama_shared/yaml"]
diff --git a/askama_shared/Cargo.toml b/askama_shared/Cargo.toml
index b75ec77..e19ad35 100644
--- a/askama_shared/Cargo.toml
+++ b/askama_shared/Cargo.toml
@@ -13,10 +13,12 @@ edition = "2018"
 default = ["config", "humansize", "num-traits", "percent-encoding"]
 config = ["serde", "toml"]
 json = ["serde", "serde_json"]
+markdown = ["comrak"]
 yaml = ["serde", "serde_yaml"]
 
 [dependencies]
 askama_escape = { version = "0.10.2", path = "../askama_escape" }
+comrak = { version = "0.12", optional = true, default-features = false }
 humansize = { version = "1.1.0", optional = true }
 mime = "0.3"
 mime_guess = "2"
diff --git a/askama_shared/src/filters/mod.rs b/askama_shared/src/filters/mod.rs
index 35bdd5a..9fb10d2 100644
--- a/askama_shared/src/filters/mod.rs
+++ b/askama_shared/src/filters/mod.rs
@@ -47,7 +47,7 @@ const URLENCODE_SET: &AsciiSet = &URLENCODE_STRICT_SET.remove(b'/');
 // Askama or should refer to a local `filters` module. It should contain all the
 // filters shipped with Askama, even the optional ones (since optional inclusion
 // in the const vector based on features seems impossible right now).
-pub const BUILT_IN_FILTERS: [&str; 27] = [
+pub const BUILT_IN_FILTERS: &[&str] = &[
     "abs",
     "capitalize",
     "center",
@@ -73,8 +73,10 @@ pub const BUILT_IN_FILTERS: [&str; 27] = [
     "urlencode",
     "urlencode_strict",
     "wordcount",
-    "json", // Optional feature; reserve the name anyway
-    "yaml", // Optional feature; reserve the name anyway
+    // optional features, reserve the names anyway:
+    "json",
+    "markdown",
+    "yaml",
 ];
 
 /// Marks a string (or other `Display` type) as safe
@@ -379,6 +381,54 @@ 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::ComrakOptions>,
+) -> Result<MarkupDisplay<E, String>>
+where
+    E: Escaper,
+    S: AsRef<str>,
+{
+    use comrak::{
+        markdown_to_html, ComrakExtensionOptions, ComrakOptions, ComrakParseOptions,
+        ComrakRenderOptions,
+    };
+
+    const DEFAULT_OPTIONS: ComrakOptions = ComrakOptions {
+        extension: ComrakExtensionOptions {
+            strikethrough: true,
+            tagfilter: true,
+            table: true,
+            autolink: true,
+            // default:
+            tasklist: false,
+            superscript: false,
+            header_ids: None,
+            footnotes: false,
+            description_lists: false,
+            front_matter_delimiter: None,
+        },
+        parse: ComrakParseOptions {
+            // default:
+            smart: false,
+            default_info_string: None,
+        },
+        render: ComrakRenderOptions {
+            unsafe_: false,
+            escape: true,
+            // default:
+            hardbreaks: false,
+            github_pre_lang: false,
+            width: 0,
+        },
+    };
+
+    let s = markdown_to_html(s.as_ref(), options.unwrap_or(&DEFAULT_OPTIONS));
+    Ok(MarkupDisplay::new_safe(s, e))
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/askama_shared/src/generator.rs b/askama_shared/src/generator.rs
index 63a0154..c3beb88 100644
--- a/askama_shared/src/generator.rs
+++ b/askama_shared/src/generator.rs
@@ -1097,6 +1097,45 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> {
         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!(
+            "::askama::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_filter(
         &mut self,
         buf: &mut Buffer,
@@ -1115,6 +1154,8 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> {
         } 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);
         }
 
         if name == "tojson" {


Test patch:
diff --git a/testing/Cargo.toml b/testing/Cargo.toml
index 008b200..bd7bbc9 100644
--- a/testing/Cargo.toml
+++ b/testing/Cargo.toml
@@ -7,10 +7,13 @@ edition = "2018"
 publish = false
 
 [features]
-default = ["serde_json", "askama/serde-json"]
+default = ["serde-json", "markdown"]
+serde-json = ["serde_json", "askama/serde-json"]
+markdown = ["comrak", "askama/markdown"]
 
 [dependencies]
 askama = { path = "../askama", version = "0.11.0-beta.1" }
+comrak = { version = "0.12", default-features = false, optional = true }
 serde_json = { version = "1.0", optional = true }
 
 [dev-dependencies]
diff --git a/testing/tests/markdown.rs b/testing/tests/markdown.rs
new file mode 100644
index 0000000..e0150f6
--- /dev/null
+++ b/testing/tests/markdown.rs
@@ -0,0 +1,75 @@
+#![cfg(feature = "markdown")]
+
+use askama::Template;
+use comrak::{ComrakOptions, ComrakRenderOptions};
+
+#[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 ComrakOptions,
+}
+
+#[test]
+fn test_markdown_with_options() {
+    let s = MarkdownWithOptionsTemplate {
+        before: "before",
+        after: "after",
+        content: "* 1\n* <script>alert('Lol, hacked!')</script>\n* 3",
+        options: &ComrakOptions {
+            render: ComrakRenderOptions {
+                unsafe_: true,
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+    };
+    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",
+    );
+}