use lazy_static::lazy_static; use pest::iterators::Pair; use pest::Parser; use pest_derive::Parser; use regex::Regex; use tera::{to_value, Context, Map, Value}; use crate::context::RenderContext; use errors::{bail, Error, Result}; // This include forces recompiling this source file if the grammar file changes. // Uncomment it when doing changes to the .pest file const _GRAMMAR: &str = include_str!("content.pest"); #[derive(Parser)] #[grammar = "content.pest"] pub struct ContentParser; lazy_static! { static ref MULTIPLE_NEWLINE_RE: Regex = Regex::new(r"\n\s*\n").unwrap(); static ref OUTER_NEWLINE_RE: Regex = Regex::new(r"^\s*\n|\n\s*$").unwrap(); } fn replace_string_markers(input: &str) -> String { match input.chars().next().unwrap() { '"' => input.replace('"', ""), '\'' => input.replace('\'', ""), '`' => input.replace('`', ""), _ => unreachable!("How did you even get there"), } } fn parse_literal(pair: Pair) -> Value { let mut val = None; for p in pair.into_inner() { match p.as_rule() { Rule::boolean => match p.as_str() { "true" => val = Some(Value::Bool(true)), "false" => val = Some(Value::Bool(false)), _ => unreachable!(), }, Rule::string => val = Some(Value::String(replace_string_markers(p.as_str()))), Rule::float => { val = Some(to_value(p.as_str().parse::().unwrap()).unwrap()); } Rule::int => { val = Some(to_value(p.as_str().parse::().unwrap()).unwrap()); } _ => unreachable!("Unknown literal: {:?}", p), }; } val.unwrap() } /// Returns (shortcode_name, kwargs) fn parse_shortcode_call(pair: Pair) -> (String, Map) { let mut name = None; let mut args = Map::new(); for p in pair.into_inner() { match p.as_rule() { Rule::ident => { name = Some(p.as_span().as_str().to_string()); } Rule::kwarg => { let mut arg_name = None; let mut arg_val = None; for p2 in p.into_inner() { match p2.as_rule() { Rule::ident => { arg_name = Some(p2.as_span().as_str().to_string()); } Rule::literal => { arg_val = Some(parse_literal(p2)); } Rule::array => { let mut vals = vec![]; for p3 in p2.into_inner() { match p3.as_rule() { Rule::literal => vals.push(parse_literal(p3)), _ => unreachable!( "Got something other than literal in an array: {:?}", p3 ), } } arg_val = Some(Value::Array(vals)); } _ => unreachable!("Got something unexpected in a kwarg: {:?}", p2), } } args.insert(arg_name.unwrap(), arg_val.unwrap()); } _ => unreachable!("Got something unexpected in a shortcode: {:?}", p), } } (name.unwrap(), args) } fn render_shortcode( name: &str, args: &Map, context: &RenderContext, body: Option<&str>, ) -> Result { let mut tera_context = Context::new(); for (key, value) in args.iter() { tera_context.insert(key, value); } if let Some(ref b) = body { // Trimming right to avoid most shortcodes with bodies ending up with a HTML new line tera_context.insert("body", b.trim_end()); } tera_context.extend(context.tera_context.clone()); let template_name = format!("shortcodes/{}.html", name); let res = utils::templates::render_template(&template_name, &context.tera, tera_context, &None) .map_err(|e| Error::chain(format!("Failed to render {} shortcode", name), e))?; // Small hack to avoid having multiple blank lines because of Tera tags for example // A blank like will cause the markdown parser to think we're out of HTML and start looking // at indentation, making the output a code block. let res = MULTIPLE_NEWLINE_RE.replace_all(&res, "\n"); let res = OUTER_NEWLINE_RE.replace_all(&res, ""); Ok(res.to_string()) } pub fn render_shortcodes(content: &str, context: &RenderContext) -> Result { let mut res = String::with_capacity(content.len()); let mut pairs = match ContentParser::parse(Rule::page, content) { Ok(p) => p, Err(e) => { let fancy_e = e.renamed_rules(|rule| match *rule { Rule::int => "an integer".to_string(), Rule::float => "a float".to_string(), Rule::string => "a string".to_string(), Rule::literal => "a literal (int, float, string, bool)".to_string(), Rule::array => "an array".to_string(), Rule::kwarg => "a keyword argument".to_string(), Rule::ident => "an identifier".to_string(), Rule::inline_shortcode => "an inline shortcode".to_string(), Rule::ignored_inline_shortcode => "an ignored inline shortcode".to_string(), Rule::sc_body_start => "the start of a shortcode".to_string(), Rule::ignored_sc_body_start => "the start of an ignored shortcode".to_string(), Rule::text => "some text".to_string(), Rule::EOI => "end of input".to_string(), Rule::double_quoted_string => "double quoted string".to_string(), Rule::single_quoted_string => "single quoted string".to_string(), Rule::backquoted_quoted_string => "backquoted quoted string".to_string(), Rule::boolean => "a boolean (true, false)".to_string(), Rule::all_chars => "a alphanumerical character".to_string(), Rule::kwargs => "a list of keyword arguments".to_string(), Rule::sc_def => "a shortcode definition".to_string(), Rule::shortcode_with_body => "a shortcode with body".to_string(), Rule::ignored_shortcode_with_body => "an ignored shortcode with body".to_string(), Rule::sc_body_end => "{% end %}".to_string(), Rule::ignored_sc_body_end => "{%/* end */%}".to_string(), Rule::text_in_body_sc => "text in a shortcode body".to_string(), Rule::text_in_ignored_body_sc => "text in an ignored shortcode body".to_string(), Rule::content => "some content".to_string(), Rule::page => "a page".to_string(), Rule::WHITESPACE => "whitespace".to_string(), }); bail!("{}", fancy_e); } }; // We have at least a `page` pair for p in pairs.next().unwrap().into_inner() { match p.as_rule() { Rule::text => res.push_str(p.as_span().as_str()), Rule::inline_shortcode => { let (name, args) = parse_shortcode_call(p); res.push_str(&render_shortcode(&name, &args, context, None)?); } Rule::shortcode_with_body => { let mut inner = p.into_inner(); // 3 items in inner: call, body, end // we don't care about the closing tag let (name, args) = parse_shortcode_call(inner.next().unwrap()); let body = inner.next().unwrap().as_span().as_str(); res.push_str(&render_shortcode(&name, &args, context, Some(body))?); } Rule::ignored_inline_shortcode => { res.push_str( &p.as_span().as_str().replacen("{{/*", "{{", 1).replacen("*/}}", "}}", 1), ); } Rule::ignored_shortcode_with_body => { for p2 in p.into_inner() { match p2.as_rule() { Rule::ignored_sc_body_start | Rule::ignored_sc_body_end => { res.push_str( &p2.as_span() .as_str() .replacen("{%/*", "{%", 1) .replacen("*/%}", "%}", 1), ); } Rule::text_in_ignored_body_sc => res.push_str(p2.as_span().as_str()), _ => unreachable!("Got something weird in an ignored shortcode: {:?}", p2), } } } Rule::EOI => (), _ => unreachable!("unexpected page rule: {:?}", p.as_rule()), } } Ok(res) } #[cfg(test)] mod tests { use std::collections::HashMap; use super::*; use config::Config; use front_matter::InsertAnchor; use tera::Tera; macro_rules! assert_lex_rule { ($rule: expr, $input: expr) => { let res = ContentParser::parse($rule, $input); println!("{:?}", $input); println!("{:#?}", res); if res.is_err() { println!("{}", res.unwrap_err()); panic!(); } assert!(res.is_ok()); assert_eq!(res.unwrap().last().unwrap().as_span().end(), $input.len()); }; } fn render_shortcodes(code: &str, tera: &Tera) -> String { let config = Config::default(); let permalinks = HashMap::new(); let context = RenderContext::new(&tera, &config, "", &permalinks, InsertAnchor::None); super::render_shortcodes(code, &context).unwrap() } #[test] fn lex_text() { let inputs = vec!["Hello world", "HEllo \n world", "Hello 1 2 true false 'hey'"]; for i in inputs { assert_lex_rule!(Rule::text, i); } } #[test] fn lex_inline_shortcode() { let inputs = vec![ "{{ youtube() }}", "{{ youtube(id=1, autoplay=true, url='hey') }}", "{{ youtube(id=1, \nautoplay=true, url='hey') }}", ]; for i in inputs { assert_lex_rule!(Rule::inline_shortcode, i); } } #[test] fn lex_inline_ignored_shortcode() { let inputs = vec![ "{{/* youtube() */}}", "{{/* youtube(id=1, autoplay=true, url='hey') */}}", "{{/* youtube(id=1, \nautoplay=true, \nurl='hey') */}}", ]; for i in inputs { assert_lex_rule!(Rule::ignored_inline_shortcode, i); } } #[test] fn lex_shortcode_with_body() { let inputs = vec![ r#"{% youtube() %} Some text {% end %}"#, r#"{% youtube(id=1, autoplay=true, url='hey') %} Some text {% end %}"#, ]; for i in inputs { assert_lex_rule!(Rule::shortcode_with_body, i); } } #[test] fn lex_ignored_shortcode_with_body() { let inputs = vec![ r#"{%/* youtube() */%} Some text {%/* end */%}"#, r#"{%/* youtube(id=1, autoplay=true, url='hey') */%} Some text {%/* end */%}"#, ]; for i in inputs { assert_lex_rule!(Rule::ignored_shortcode_with_body, i); } } #[test] fn lex_page() { let inputs = vec![ "Some text and a shortcode `{{/* youtube() */}}`", "{{ youtube(id=1, autoplay=true, url='hey') }}", "{{ youtube(id=1, \nautoplay=true, url='hey') }} that's it", r#" This is a test {% hello() %} Body {{ var }} {% end %} "#, ]; for i in inputs { assert_lex_rule!(Rule::page, i); } } #[test] fn does_nothing_with_no_shortcodes() { let res = render_shortcodes("Hello World", &Tera::default()); assert_eq!(res, "Hello World"); } #[test] fn can_unignore_inline_shortcode() { let res = render_shortcodes("Hello World {{/* youtube() */}}", &Tera::default()); assert_eq!(res, "Hello World {{ youtube() }}"); } #[test] fn can_unignore_shortcode_with_body() { let res = render_shortcodes( r#" Hello World {%/* youtube() */%}Some body {{ hello() }}{%/* end */%}"#, &Tera::default(), ); assert_eq!(res, "\nHello World\n{% youtube() %}Some body {{ hello() }}{% end %}"); } // https://github.com/Keats/gutenberg/issues/383 #[test] fn unignore_shortcode_with_body_does_not_swallow_initial_whitespace() { let res = render_shortcodes( r#" Hello World {%/* youtube() */%} Some body {{ hello() }}{%/* end */%}"#, &Tera::default(), ); assert_eq!(res, "\nHello World\n{% youtube() %}\nSome body {{ hello() }}{% end %}"); } #[test] fn can_parse_shortcode_arguments() { let inputs = vec![ ("{{ youtube() }}", "youtube", Map::new()), ("{{ youtube(id=1, autoplay=true, hello='salut', float=1.2) }}", "youtube", { let mut m = Map::new(); m.insert("id".to_string(), to_value(1).unwrap()); m.insert("autoplay".to_string(), to_value(true).unwrap()); m.insert("hello".to_string(), to_value("salut").unwrap()); m.insert("float".to_string(), to_value(1.2).unwrap()); m }), ("{{ gallery(photos=['something', 'else'], fullscreen=true) }}", "gallery", { let mut m = Map::new(); m.insert("photos".to_string(), to_value(["something", "else"]).unwrap()); m.insert("fullscreen".to_string(), to_value(true).unwrap()); m }), ]; for (i, n, a) in inputs { let mut res = ContentParser::parse(Rule::inline_shortcode, i).unwrap(); let (name, args) = parse_shortcode_call(res.next().unwrap()); assert_eq!(name, n); assert_eq!(args, a); } } #[test] fn can_render_inline_shortcodes() { let mut tera = Tera::default(); tera.add_raw_template("shortcodes/youtube.html", "Hello {{id}}").unwrap(); let res = render_shortcodes("Inline {{ youtube(id=1) }}.", &tera); assert_eq!(res, "Inline Hello 1."); } #[test] fn can_render_shortcodes_with_body() { let mut tera = Tera::default(); tera.add_raw_template("shortcodes/youtube.html", "{{body}}").unwrap(); let res = render_shortcodes("Body\n {% youtube() %}Hey!{% end %}", &tera); assert_eq!(res, "Body\n Hey!"); } // https://github.com/Keats/gutenberg/issues/462 #[test] fn shortcodes_with_body_do_not_eat_newlines() { let mut tera = Tera::default(); tera.add_raw_template("shortcodes/youtube.html", "{{body | safe}}").unwrap(); let res = render_shortcodes("Body\n {% youtube() %}\nHello \n World{% end %}", &tera); assert_eq!(res, "Body\n Hello \n World"); } #[test] fn outer_newlines_removed_from_shortcodes_with_body() { let mut tera = Tera::default(); tera.add_raw_template("shortcodes/youtube.html", " \n {{body}} \n ").unwrap(); let res = render_shortcodes("\n{% youtube() %} \n content \n {% end %}\n", &tera); assert_eq!(res, "\n content \n"); } #[test] fn outer_newlines_removed_from_inline_shortcodes() { let mut tera = Tera::default(); tera.add_raw_template("shortcodes/youtube.html", " \n Hello, Zola. \n ").unwrap(); let res = render_shortcodes("\n{{ youtube() }}\n", &tera); assert_eq!(res, "\n Hello, Zola. \n"); } }