Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow splices in attribute names #445

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/content/splices-toggles.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ html! {
# ;
```

### Splices in attribute name

You can also use splices in the attribute name:

```rust
let tuple = ("hx-get", "/pony");
# let _ = maud::
html! {
button (tuple.0)=(tuple.1) {
"Get a pony!"
}
}
# ;
```

### What can be spliced?

You can splice any value that implements [`Render`][Render].
Expand Down
62 changes: 62 additions & 0 deletions maud/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,68 @@ pub mod macro_private {
use alloc::string::String;
use core::fmt::Display;

pub fn strip_to_attr_name(input: impl AsRef<str>, output: &mut String) {
for c in input.as_ref().chars() {
match c {
' '
| '"'
| '\''
| '>'
| '/'
| '='
| '\u{0000}'..='\u{001F}'
| '\u{007F}'..='\u{009F}'
| '\u{FDD0}'..='\u{FDEF}'
| '\u{FFFE}'
| '\u{FFFF}'
| '\u{1FFFE}'
| '\u{1FFFF}'
| '\u{2FFFE}'
| '\u{2FFFF}'
| '\u{3FFFE}'
| '\u{3FFFF}'
| '\u{4FFFE}'
| '\u{4FFFF}'
| '\u{5FFFE}'
| '\u{5FFFF}'
| '\u{6FFFE}'
| '\u{6FFFF}'
| '\u{7FFFE}'
| '\u{7FFFF}'
| '\u{8FFFE}'
| '\u{8FFFF}'
| '\u{9FFFE}'
| '\u{9FFFF}'
| '\u{AFFFE}'
| '\u{AFFFF}'
| '\u{BFFFE}'
| '\u{BFFFF}'
| '\u{CFFFE}'
| '\u{CFFFF}'
| '\u{DFFFE}'
| '\u{DFFFF}'
| '\u{EFFFE}'
| '\u{EFFFF}'
| '\u{FFFFE}'
| '\u{FFFFF}'
| '\u{10FFFE}'
| '\u{10FFFF}' => (),
_ => output.push(c),
}
}
}

#[doc(hidden)]
#[macro_export]
macro_rules! render_attr_name {
($x:expr, $buffer:expr) => {{
use $crate::macro_private::strip_to_attr_name;
strip_to_attr_name($x, $buffer);
}};
}

pub use render_attr_name;

#[doc(hidden)]
#[macro_export]
macro_rules! render_to {
Expand Down
24 changes: 24 additions & 0 deletions maud/tests/splices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,30 @@ fn locals() {
assert_eq!(result.into_string(), "Pinkie Pie");
}

#[test]
fn attribute_name() {
let tuple = ("hx-get", "/pony");
let result = html! { button (tuple.0)=(tuple.1) { "Get a pony!" } };
assert_eq!(
result.into_string(),
r#"<button hx-get="/pony">Get a pony!</button>"#
);
}

#[test]
fn no_xss_from_spliced_attributes() {
let evil_tuple = (
"x onclick=\"alert(42);\" x",
"\" onclick=alert(24); href=\"",
);
let result =
html! { button (format!("data-{}", evil_tuple.0))=(evil_tuple.1) { "XSS be gone!" } };
assert_eq!(
result.into_string(),
r#"<button data-xonclickalert(42);x="&quot; onclick=alert(24); href=&quot;">XSS be gone!</button>"#
);
}

/// An example struct, for testing purposes only
struct Creature {
name: &'static str,
Expand Down
29 changes: 27 additions & 2 deletions maud_macros/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,13 @@ impl Special {

#[derive(Debug)]
pub struct NamedAttr {
pub name: TokenStream,
pub name: AttrName,
pub attr_type: AttrType,
}

impl NamedAttr {
fn span(&self) -> SpanRange {
let name_span = span_tokens(self.name.clone());
let name_span = span_tokens(self.name.tokens());
if let Some(attr_type_span) = self.attr_type.span() {
name_span.join_range(attr_type_span)
} else {
Expand All @@ -168,6 +168,31 @@ impl NamedAttr {
}
}

#[derive(Debug, Clone)]
pub enum AttrName {
Fixed { value: TokenStream },
Splice { expr: TokenStream },
}

impl AttrName {
pub fn tokens(&self) -> TokenStream {
match self {
AttrName::Fixed { value } => value.clone(),
AttrName::Splice { expr, .. } => expr.clone(),
}
}
}

impl std::fmt::Display for AttrName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AttrName::Fixed { value } => f.write_str(&value.to_string())?,
AttrName::Splice { expr, .. } => f.write_str(&expr.to_string())?,
};
Ok(())
}
}

#[derive(Debug)]
pub enum AttrType {
Normal { value: Markup },
Expand Down
26 changes: 21 additions & 5 deletions maud_macros/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ impl Generator {
build.push_tokens(quote!(maud::macro_private::render_to!(&(#expr), &mut #output_ident);));
}

fn splice_attr_name(&self, expr: TokenStream, build: &mut Builder) {
let output_ident = self.output_ident.clone();
build.push_tokens(
quote!(maud::macro_private::render_attr_name!(&(#expr), &mut #output_ident);),
);
}

fn element(&self, name: TokenStream, attrs: Vec<Attr>, body: ElementBody, build: &mut Builder) {
build.push_str("<");
self.name(name.clone(), build);
Expand All @@ -123,12 +130,19 @@ impl Generator {
build.push_escaped(&name_to_string(name));
}

fn attr_name(&self, name: AttrName, build: &mut Builder) {
match name {
AttrName::Fixed { value } => self.name(value, build),
AttrName::Splice { expr, .. } => self.splice_attr_name(expr, build),
}
}

fn attrs(&self, attrs: Vec<Attr>, build: &mut Builder) {
for NamedAttr { name, attr_type } in desugar_attrs(attrs) {
match attr_type {
AttrType::Normal { value } => {
build.push_str(" ");
self.name(name, build);
self.attr_name(name, build);
build.push_str("=\"");
self.markup(value, build);
build.push_str("\"");
Expand All @@ -140,7 +154,7 @@ impl Generator {
let body = {
let mut build = self.builder();
build.push_str(" ");
self.name(name, &mut build);
self.attr_name(name, &mut build);
build.push_str("=\"");
self.splice(inner_value.clone(), &mut build);
build.push_str("\"");
Expand All @@ -150,15 +164,15 @@ impl Generator {
}
AttrType::Empty { toggler: None } => {
build.push_str(" ");
self.name(name, build);
self.attr_name(name, build);
}
AttrType::Empty {
toggler: Some(Toggler { cond, .. }),
} => {
let body = {
let mut build = self.builder();
build.push_str(" ");
self.name(name, &mut build);
self.attr_name(name, &mut build);
build.finish()
};
build.push_tokens(quote!(if (#cond) { #body }));
Expand Down Expand Up @@ -224,7 +238,9 @@ fn desugar_classes_or_ids(
});
}
Some(NamedAttr {
name: TokenStream::from(TokenTree::Ident(Ident::new(attr_name, Span::call_site()))),
name: AttrName::Fixed {
value: TokenStream::from(TokenTree::Ident(Ident::new(attr_name, Span::call_site()))),
},
attr_type: AttrType::Normal {
value: Markup::Block(Block {
markups,
Expand Down
103 changes: 59 additions & 44 deletions maud_macros/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub fn parse(input: TokenStream) -> Vec<ast::Markup> {
#[derive(Clone)]
struct Parser {
/// If we're inside an attribute, then this contains the attribute name.
current_attr: Option<String>,
current_attr: Option<ast::AttrName>,
input: <TokenStream as IntoIterator>::IntoIter,
}

Expand Down Expand Up @@ -580,48 +580,7 @@ impl Parser {
let mut attrs = Vec::new();
loop {
if let Some(name) = self.try_namespaced_name() {
// Attribute
match self.peek() {
// Non-empty attribute
Some(TokenTree::Punct(ref punct)) if punct.as_char() == '=' => {
self.advance();
// Parse a value under an attribute context
assert!(self.current_attr.is_none());
self.current_attr = Some(ast::name_to_string(name.clone()));
let attr_type = match self.attr_toggler() {
Some(toggler) => ast::AttrType::Optional { toggler },
None => {
let value = self.markup();
ast::AttrType::Normal { value }
}
};
self.current_attr = None;
attrs.push(ast::Attr::Named {
named_attr: ast::NamedAttr { name, attr_type },
});
}
// Empty attribute (legacy syntax)
Some(TokenTree::Punct(ref punct)) if punct.as_char() == '?' => {
self.advance();
let toggler = self.attr_toggler();
attrs.push(ast::Attr::Named {
named_attr: ast::NamedAttr {
name: name.clone(),
attr_type: ast::AttrType::Empty { toggler },
},
});
}
// Empty attribute (new syntax)
_ => {
let toggler = self.attr_toggler();
attrs.push(ast::Attr::Named {
named_attr: ast::NamedAttr {
name: name.clone(),
attr_type: ast::AttrType::Empty { toggler },
},
});
}
}
attrs.push(self.attr(ast::AttrName::Fixed { value: name }));
} else {
match self.peek() {
// Class shorthand
Expand All @@ -644,6 +603,18 @@ impl Parser {
name,
});
}
// Spliced attribute name
Some(TokenTree::Group(ref group))
if group.delimiter() == Delimiter::Parenthesis =>
{
match self.markup() {
ast::Markup::Splice { expr, .. } => {
attrs.push(self.attr(ast::AttrName::Splice { expr }));
}
// If it's not a splice, backtrack and bail out
_ => break,
}
}
// If it's not a valid attribute, backtrack and bail out
_ => break,
}
Expand All @@ -665,7 +636,7 @@ impl Parser {
ast::Attr::Id { .. } => "id".to_string(),
ast::Attr::Named { named_attr } => named_attr
.name
.clone()
.tokens()
.into_iter()
.map(|token| token.to_string())
.collect(),
Expand All @@ -685,6 +656,50 @@ impl Parser {
attrs
}

fn attr(&mut self, name: ast::AttrName) -> ast::Attr {
match self.peek() {
// Non-empty attribute
Some(TokenTree::Punct(ref punct)) if punct.as_char() == '=' => {
self.advance();
// Parse a value under an attribute context
assert!(self.current_attr.is_none());
self.current_attr = Some(name.clone());
let attr_type = match self.attr_toggler() {
Some(toggler) => ast::AttrType::Optional { toggler },
None => {
let value = self.markup();
ast::AttrType::Normal { value }
}
};
self.current_attr = None;
ast::Attr::Named {
named_attr: ast::NamedAttr { name, attr_type },
}
}
// Empty attribute (legacy syntax)
Some(TokenTree::Punct(ref punct)) if punct.as_char() == '?' => {
self.advance();
let toggler = self.attr_toggler();
ast::Attr::Named {
named_attr: ast::NamedAttr {
name,
attr_type: ast::AttrType::Empty { toggler },
},
}
}
// Empty attribute (new syntax)
_ => {
let toggler = self.attr_toggler();
ast::Attr::Named {
named_attr: ast::NamedAttr {
name,
attr_type: ast::AttrType::Empty { toggler },
},
}
}
}
}

/// Parses the name of a class or ID.
fn class_or_id_name(&mut self) -> ast::Markup {
if let Some(symbol) = self.try_name() {
Expand Down
Loading