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..2c9e00768 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", @@ -75,6 +75,8 @@ pub const BUILT_IN_FILTERS: [&str; 27] = [ "wordcount", "json", // Optional feature; reserve the name anyway "yaml", // Optional feature; reserve the name anyway + #[cfg(feature = "markdown")] + "markdown", ]; /// Marks a string (or other `Display` type) as safe @@ -379,6 +381,61 @@ pub fn wordcount(s: T) -> Result { Ok(s.split_whitespace().count()) } +#[cfg(feature = "markdown")] +pub fn markdown<'a, E: Escaper, S: std::borrow::Borrow<&'a str>>( + e: E, + s: S, + options: Option<&comrak::ComrakOptions>, + plugins: Option<&comrak::ComrakPlugins<'_>>, +) -> Result> { + use comrak::{ + markdown_to_html_with_plugins, ComrakExtensionOptions, ComrakOptions, ComrakParseOptions, + ComrakPlugins, ComrakRenderOptions, ComrakRenderPlugins, + }; + + 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, + }, + }; + + const DEFAULT_PLUGINS: ComrakPlugins<'static> = ComrakPlugins { + render: ComrakRenderPlugins { + codefence_syntax_highlighter: None, + }, + }; + + let s = markdown_to_html_with_plugins( + s.borrow(), + options.unwrap_or(&DEFAULT_OPTIONS), + plugins.unwrap_or(&DEFAULT_PLUGINS), + ); + 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 daf22aee1..0e09ab666 100644 --- a/askama_shared/src/generator.rs +++ b/askama_shared/src/generator.rs @@ -1130,6 +1130,45 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> { return Err("the `yaml` filter requires the `serde-yaml` feature to be enabled".into()); } + #[cfg(feature = "markdown")] + if name == "markdown" { + let (md, options, plugins) = match args { + [md] => (md, None, None), + [md, options] => (md, Some(options), None), + [md, options, plugins] => (md, Some(options), Some(plugins)), + args => { + return Err(format!( + "markdown filter expects 1 to 3 arguments, not {}", + args.len() + ) + .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"), + } + match plugins { + Some(plugins) => { + buf.write(", ::core::option::Option::Some("); + self.visit_expr(buf, plugins)?; + buf.write(")"); + } + None => buf.write(", ::core::option::Option::None"), + } + buf.write(")?"); + return Ok(DisplayWrap::Wrapped); + } + const FILTERS: [&str; 3] = ["safe", "json", "yaml"]; if FILTERS.contains(&name) { buf.write(&format!( diff --git a/testing/Cargo.toml b/testing/Cargo.toml index 008b20045..602a8ff92 100644 --- a/testing/Cargo.toml +++ b/testing/Cargo.toml @@ -7,13 +7,13 @@ edition = "2018" publish = false [features] -default = ["serde_json", "askama/serde-json"] +default = ["askama/serde-json", "askama/markdown"] [dependencies] askama = { path = "../askama", version = "0.11.0-beta.1" } -serde_json = { version = "1.0", optional = true } [dev-dependencies] +comrak = { version = "0.12", default-features = false } criterion = "0.3" trybuild = "1.0.55" version_check = "0.9" diff --git a/testing/tests/markdown.rs b/testing/tests/markdown.rs new file mode 100644 index 000000000..b21307578 --- /dev/null +++ b/testing/tests/markdown.rs @@ -0,0 +1,73 @@ +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\ +
  • +<script>alert('Lol, hacked!')</script> +
  • \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\ +
  • 3
  • \n\ +
\n\ +after", + ); +}