diff --git a/askama/Cargo.toml b/askama/Cargo.toml index 281e23237..3dc305e79 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 b75ec77c1..e19ad359b 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 35bdd5a64..9fb10d2fe 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(s: T) -> Result { Ok(s.split_whitespace().count()) } +#[cfg(feature = "markdown")] +pub fn markdown( + e: E, + s: S, + options: Option<&comrak::ComrakOptions>, +) -> Result> +where + E: Escaper, + S: AsRef, +{ + 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 63a0154ad..c3beb88b8 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 { + 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 { + 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" { diff --git a/testing/Cargo.toml b/testing/Cargo.toml index 008b20045..bd7bbc97b 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 000000000..e0150f67d --- /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* \n* 3", + }; + assert_eq!( + s.render().unwrap(), + "\ +before\ +
    \n\ +
  • 1
  • \n\ +
  • \n\ +<script>alert('Lol, hacked!')</script>\n\ +
  • \n\ +
  • 3
  • \n\ +
\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* \n* 3", + options: &ComrakOptions { + render: ComrakRenderOptions { + unsafe_: true, + ..Default::default() + }, + ..Default::default() + }, + }; + assert_eq!( + s.render().unwrap(), + "\ +before\ +
    \n\ +
  • 1
  • \n\ +
  • \n\ +\n\ +
  • \n\ +
  • 3
  • \n\ +
\n\ +after", + ); +}