djc / askama

Type-safe, compiled Jinja-like templates for Rust
Apache License 2.0
3.36k stars 217 forks source link

Multiple outputs? #858

Open axos88 opened 1 year ago

axos88 commented 1 year ago

I am trying to use askama for generating emails, where both the subject and the body depends on the input variables.

How hard would it be to implement the ability to do something like:

struct Subject;
struct Body;

#[derive(Template)]
#[template(path = "email_subject.askama", escape = "None", part = Subject)
#[template(path = "email_subject.askama", escape = "None", part = Body)
struct EmailData {... }

let data = EmailData:new(....)

data.render::<Subject>() // Returns the subject
data.render::<Body>() // Returns the body.

data.render_part(Subject) // Returns the subject
data.render_part(Body) // Returns the body.

// I guess this won't work
data.render_subject() // Returns the subject
data.render_body() // Returns the body.

If part is not given, then I guess a renderer could be used by default to remain backwards compatible, also in most cases only one renderer is attributed to a data template.

djc commented 1 year ago

In this case, are the values needed by the subject a subset of the values needed by the body? If so, I'd just use one type for the former and a second type for the latter, which includes the former in one of its fields.

At work, we use a procedural macro to duplicate a type for emails that we send with both plaintext and HTML bodies.

Usage:

#[email(template = "contact-email")]
pub struct ContactEmail<'a> {
    pub site_name: &'a str,
    pub sender: &'a str,
    pub message: &'a str,
    pub inbox_url: &'a str,
}

Macro:

#[proc_macro_attribute]
pub fn email(meta: TokenStream, item: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(item as syn::DeriveInput);
    let meta = parse_macro_input!(meta as EmailMeta);

    // Introduce an `'email` lifetime for the reference to outer type.
    // We set up the generics such that all lifetimes used on the item should outlive the
    // `'email` lifetime, which is necessary to make some of the impls below work.

    let mut email_generics = ast.generics.clone();
    let email_lifetime = syn::LifetimeParam::new(syn::Lifetime::new("'email", Span::call_site()));
    for lt in email_generics.lifetimes_mut() {
        lt.bounds.push(email_lifetime.lifetime.clone());
    }
    email_generics
        .params
        .push(syn::GenericParam::Lifetime(email_lifetime));

    // Split the generics for use in impls and type definitions, below.

    let (_, inner_ty_generics, _) = ast.generics.split_for_impl();
    let (impl_generics, ty_generics, where_clause) = email_generics.split_for_impl();

    // Set up some bindings for use in the quote!() call below.

    let visibility = &ast.vis;
    let name = &ast.ident;
    let text_type = Ident::new(&format!("{name}Text"), Span::call_site());
    let text_template = format!("{}.txt", &meta.template);
    let html_type = Ident::new(&format!("{name}Html"), Span::call_site());
    let html_template = format!("{}.html", &meta.template);

    quote!(
        #ast

        impl #impl_generics email::BodyTemplates<'email> for #name #inner_ty_generics #where_clause {
            type Text = #text_type #ty_generics;
            type Html = #html_type #ty_generics;
        }

        #[derive(askama::Template)]
        #[template(path = #text_template)]
        #visibility struct #text_type #ty_generics(&'email #name #inner_ty_generics) #where_clause;

        impl #impl_generics From<&'email #name #inner_ty_generics> for #text_type #ty_generics #where_clause {
            fn from(email: &'email #name #inner_ty_generics) -> Self {
                Self(email)
            }
        }

        impl #impl_generics std::ops::Deref for #text_type #ty_generics {
            type Target = &'email #name #inner_ty_generics;

            fn deref(&self) -> &Self::Target {
                &self.0
            }
        }

        #[derive(askama::Template)]
        #[template(path = #html_template)]
        #visibility struct #html_type #ty_generics(&'email #name #inner_ty_generics) #where_clause;

        impl #impl_generics std::ops::Deref for #html_type #ty_generics  {
            type Target = &'email #name #inner_ty_generics;

            fn deref(&self) -> &Self::Target {
                &self.0
            }
        }

        impl #impl_generics From<&'email #name #inner_ty_generics> for #html_type #ty_generics #where_clause {
            fn from(email: &'email #name #inner_ty_generics) -> Self {
                Self(email)
            }
        }
    ).into()
}

struct EmailMeta {
    template: String,
}

impl Parse for EmailMeta {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        for field in Punctuated::<syn::MetaNameValue, Comma>::parse_terminated(input)? {
            if field.path.is_ident("template") {
                if let syn::Expr::Lit(lit) = &field.value {
                    if let syn::Lit::Str(lit) = &lit.lit {
                        return Ok(Self {
                            template: lit.value(),
                        });
                    }
                }
            }
        }

        panic!("require template key for email macro");
    }
}
dvtkrlbs commented 1 month ago

hey is it possible you could post a gist with the whole macro and related traits extracted? I am really curious about the BodyTemplates trait.

djc commented 1 month ago

Pretty sure that is already the whole macro.

pub trait BodyTemplates<'email>: 'email {
    type Text: From<&'email Self> + askama::Template;
    type Html: From<&'email Self> + askama::Template;

    fn text(&'email self) -> Result<String, askama::Error> {
        Template::render(&Self::Text::from(self))
    }

    fn html(&'email self) -> Result<String, askama::Error> {
        Template::render(&Self::Html::from(self))
    }
}

It should be super straightforward to do your own from here?