diff --git a/packages/rsx-hotreload/tests/hotreload_pattern.rs b/packages/rsx-hotreload/tests/hotreload_pattern.rs
index 8a3a8c89f1..f87693e1b6 100644
--- a/packages/rsx-hotreload/tests/hotreload_pattern.rs
+++ b/packages/rsx-hotreload/tests/hotreload_pattern.rs
@@ -1301,3 +1301,38 @@ fn valid_spread() {
assert!(valid);
}
+
+// JSX-style syntax should hot reload, including when mixed with the regular syntax
+#[test]
+fn valid_jsx_syntax() {
+ // Literal changes in JSX-style rsx are hot reloadable
+ let valid = can_hotreload(
+ quote! {
+
+
"Hello, world!"
+
+ },
+ quote! {
+
+
"Goodbye, world!"
+
+ },
+ );
+ assert!(valid);
+
+ // Rewriting between the two syntax styles is hot reloadable since they
+ // produce the same templates
+ let valid = can_hotreload(
+ quote! {
+ div { class: "container",
+ h1 { "Hello, world!" }
+ }
+ },
+ quote! {
+
+
"Hello, world!"
+
+ },
+ );
+ assert!(valid);
+}
diff --git a/packages/rsx/src/component.rs b/packages/rsx/src/component.rs
index 4c10edfbbe..966f3dc38f 100644
--- a/packages/rsx/src/component.rs
+++ b/packages/rsx/src/component.rs
@@ -56,23 +56,15 @@ impl Parse for Component {
diagnostics,
} = input.parse::()?;
- let mut component = Self {
- children: TemplateBody::new(children),
+ Ok(Self::from_parts(
name,
generics,
fields,
- brace: Some(brace),
spreads,
+ children,
+ Some(brace),
diagnostics,
- };
-
- // We've received a valid rsx block, but it's not necessarily a valid component
- // validating it will dump diagnostics into the output
- component.validate_component_path();
- component.validate_fields();
- component.validate_component_spread();
-
- Ok(component)
+ ))
}
}
@@ -121,6 +113,35 @@ impl Component {
}
}
+ /// Assemble a component from its parsed parts, running validation on the result
+ pub(crate) fn from_parts(
+ name: syn::Path,
+ generics: Option,
+ fields: Vec,
+ spreads: Vec,
+ children: Vec,
+ brace: Option,
+ diagnostics: Diagnostics,
+ ) -> Self {
+ let mut component = Self {
+ children: TemplateBody::new(children),
+ name,
+ generics,
+ fields,
+ brace,
+ spreads,
+ diagnostics,
+ };
+
+ // We've received a valid rsx block, but it's not necessarily a valid component
+ // validating it will dump diagnostics into the output
+ component.validate_component_path();
+ component.validate_fields();
+ component.validate_component_spread();
+
+ component
+ }
+
// Make sure this a proper component path (uppercase ident, a path, or contains an underscorea)
// This should be validated by the RsxBlock parser when it peeks bodynodes
fn validate_component_path(&mut self) {
@@ -378,7 +399,7 @@ impl Component {
/// Normalize the generics of a path
///
/// Ensure there's a `::` after the last segment if there are generics
-fn normalize_path(name: &mut syn::Path) -> Option {
+pub(crate) fn normalize_path(name: &mut syn::Path) -> Option {
let seg = name.segments.last_mut()?;
let mut generics = match seg.arguments.clone() {
diff --git a/packages/rsx/src/element.rs b/packages/rsx/src/element.rs
index 952c530236..7653776acb 100644
--- a/packages/rsx/src/element.rs
+++ b/packages/rsx/src/element.rs
@@ -67,8 +67,46 @@ impl Parse for Element {
),
}
+ Ok(Self::from_parts(
+ name,
+ block.attributes,
+ block.spreads,
+ block.children,
+ brace,
+ block.diagnostics,
+ ))
+ }
+}
+
+impl ToTokens for Element {
+ fn to_tokens(&self, tokens: &mut TokenStream2) {
+ let builder = self.view_builder_pieces();
+ let definitions = builder.definitions();
+ let view = builder.view_expr();
+ let diagnostics = &self.diagnostics;
+
+ tokens.append_all(quote! {
+ {
+ #diagnostics
+ #(#definitions)*
+ #view
+ }
+ });
+ }
+}
+
+impl Element {
+ /// Assemble an element from its parsed parts, merging attributes and spreads
+ pub(crate) fn from_parts(
+ name: ElementName,
+ mut attributes: Vec,
+ spreads: Vec,
+ children: Vec,
+ brace: Option,
+ diagnostics: Diagnostics,
+ ) -> Self {
// Make sure these attributes have element context for name and namespace resolution.
- for attr in block.attributes.iter_mut() {
+ for attr in attributes.iter_mut() {
attr.el_name = Some(name.clone());
}
@@ -76,10 +114,10 @@ impl Parse for Element {
let mut element = Element {
brace,
name: name.clone(),
- raw_attributes: block.attributes,
- children: block.children,
- diagnostics: block.diagnostics,
- spreads: block.spreads.clone(),
+ raw_attributes: attributes,
+ children,
+ diagnostics,
+ spreads: spreads.clone(),
merged_attributes: Vec::new(),
};
@@ -90,7 +128,7 @@ impl Parse for Element {
// And then merge the spreads *after* the attributes are merged. This ensures walking the
// merged attributes in path order stops before we hit the spreads, but spreads are still
// counted as dynamic attributes
- for spread in block.spreads.iter() {
+ for spread in spreads.iter() {
element.merged_attributes.push(Attribute {
name: AttributeName::Spread(spread.dots),
colon: None,
@@ -100,28 +138,9 @@ impl Parse for Element {
});
}
- Ok(element)
- }
-}
-
-impl ToTokens for Element {
- fn to_tokens(&self, tokens: &mut TokenStream2) {
- let builder = self.view_builder_pieces();
- let definitions = builder.definitions();
- let view = builder.view_expr();
- let diagnostics = &self.diagnostics;
-
- tokens.append_all(quote! {
- {
- #diagnostics
- #(#definitions)*
- #view
- }
- });
+ element
}
-}
-impl Element {
pub(crate) fn add_merging_non_string_diagnostic(diagnostics: &mut Diagnostics, span: Span) {
diagnostics.push(span.error("Cannot merge non-fmt literals").help(
"Only formatted strings can be merged together. If you want to merge literals, you can use a format string.",
diff --git a/packages/rsx/src/jsx.rs b/packages/rsx/src/jsx.rs
new file mode 100644
index 0000000000..96bd4db3a7
--- /dev/null
+++ b/packages/rsx/src/jsx.rs
@@ -0,0 +1,530 @@
+//! JSX/XML-like syntax for the rsx! macro
+//!
+//! The rsx! macro also accepts a JSX/XML-like tag syntax which can be freely mixed with the
+//! regular block-based syntax. A `<` token switches the parser into JSX mode:
+//!
+//! ```rust, ignore
+//! rsx! {
+//!
+//!
"Hello, world!"
+//!
+//! "children"
+//!
+//! // The regular syntax can be used inside JSX children (and vice versa)
+//! div { class: "inner", "More content" }
+//! for item in items {
+//! "{item}"
+//! }
+//!
+//! }
+//! ```
+//!
+//! The syntax follows the regular rsx! rules:
+//! - Text nodes are quoted string literals (`
"Hello"
`)
+//! - Attribute values are literals (`class="abc"`) or braced expressions (`onclick={move |_| ...}`)
+//! - Shorthand attributes are supported (`` is `class: class`)
+//! - Spread attributes use `{..props}`
+//!
+//! The parsed result is the same [`Element`]/[`Component`] AST as the regular syntax, so
+//! templates, hot-reloading, and diagnostics work identically.
+
+use crate::innerlude::*;
+use proc_macro2::{Delimiter, Group, TokenStream as TokenStream2};
+use syn::{
+ Expr, Ident, LitBool, LitFloat, LitInt, LitStr, Token, braced,
+ ext::IdentExt,
+ parse::{Parse, ParseBuffer, ParseStream},
+ punctuated::Punctuated,
+ spanned::Spanned,
+ token::Brace,
+};
+
+/// Parse a JSX/XML-like tag into a [`BodyNode`]. Expects the stream to be pointing at a `<` token.
+pub(crate) fn parse_jsx_node(stream: ParseStream) -> syn::Result {
+ let lt = stream.parse::()?;
+
+ if stream.peek(Token![/]) {
+ return Err(syn::Error::new(
+ lt.span,
+ "encountered a closing tag without a matching opening tag",
+ ));
+ }
+
+ if stream.peek(Token![>]) {
+ return Err(syn::Error::new(
+ lt.span,
+ "fragments (`<>`) are not supported - list the children directly instead",
+ ));
+ }
+
+ // Decide between an element and a component using the same rules as the regular syntax:
+ // - idents followed by a dash are web components
+ // - a single lowercase ident with no underscores is an element
+ // - everything else is a component
+ let is_element = if stream.peek(Ident::peek_any) && stream.peek2(Token![-]) {
+ true
+ } else if stream.peek(Ident::peek_any) && !stream.peek2(Token![::]) {
+ let ident = parse_raw_ident(&stream.fork())?;
+ let name = ident.to_string();
+ name.chars().next().unwrap().is_ascii_lowercase() && !name.contains('_')
+ } else {
+ false
+ };
+
+ if is_element {
+ parse_element(stream)
+ } else {
+ parse_component(stream)
+ }
+}
+
+fn parse_element(stream: ParseStream) -> syn::Result {
+ let name = stream.parse::()?;
+ let (attributes, spreads) = parse_attributes(stream)?;
+
+ let (brace, children) = parse_tag_end_and_children(stream, &name.to_string(), |close| {
+ let close_name = close.parse::()?;
+ if close_name != name {
+ return Err(syn::Error::new(
+ close_name.span(),
+ format!("closing tag `{close_name}>` does not match opening tag `<{name}>`"),
+ ));
+ }
+ Ok(())
+ })?;
+
+ Ok(BodyNode::Element(Element::from_parts(
+ name,
+ attributes,
+ spreads,
+ children,
+ Some(brace),
+ Diagnostics::new(),
+ )))
+}
+
+fn parse_component(stream: ParseStream) -> syn::Result {
+ let mut name = stream.parse::()?;
+ let generics = normalize_path(&mut name);
+
+ let (fields, spreads) = parse_attributes(stream)?;
+
+ let name_string = path_to_string(&name);
+ let (brace, children) = parse_tag_end_and_children(stream, &name_string, |close| {
+ let mut close_name = close.parse::()?;
+ normalize_path(&mut close_name);
+ if path_to_string(&close_name) != name_string {
+ return Err(syn::Error::new(
+ close_name.span(),
+ format!(
+ "closing tag `{}>` does not match opening tag `<{}>`",
+ path_to_string(&close_name),
+ name_string
+ ),
+ ));
+ }
+ Ok(())
+ })?;
+
+ Ok(BodyNode::Component(Component::from_parts(
+ name,
+ generics,
+ fields,
+ spreads,
+ children,
+ Some(brace),
+ Diagnostics::new(),
+ )))
+}
+
+/// Parse the attributes of an open tag, stopping at `/>` or `>`
+fn parse_attributes(stream: ParseStream) -> syn::Result<(Vec, Vec)> {
+ let mut attributes = Vec::new();
+ let mut spreads = Vec::new();
+
+ loop {
+ if stream.peek(Token![/]) || stream.peek(Token![>]) {
+ break;
+ }
+
+ if stream.is_empty() {
+ return Err(stream.error("expected `>` or `/>` to close the tag"));
+ }
+
+ // Spread attributes: `{..expr}`
+ if stream.peek(Brace) {
+ let content: ParseBuffer;
+ braced!(content in stream);
+ let dots = content.parse::().map_err(|_| {
+ syn::Error::new(
+ content.span(),
+ "expected a spread attribute (`{..expr}`) - other braced expressions are not valid in a tag",
+ )
+ })?;
+ let expr = content.parse::()?;
+ spreads.push(Spread {
+ dots,
+ expr,
+ comma: None,
+ });
+ continue;
+ }
+
+ // Attribute names are either string literals (custom attributes) or (dash-separated) idents
+ let name = if stream.peek(LitStr) {
+ AttributeName::Custom(stream.parse::()?)
+ } else {
+ let raw = Punctuated::::parse_separated_nonempty_with(
+ stream,
+ parse_raw_ident,
+ )?;
+ if raw.len() == 1 {
+ AttributeName::BuiltIn(raw.into_iter().next().unwrap())
+ } else {
+ let span = raw.span();
+ let name = raw
+ .into_iter()
+ .map(|ident| ident.to_string())
+ .collect::>()
+ .join("-");
+ AttributeName::Custom(LitStr::new(&name, span))
+ }
+ };
+
+ let value = if stream.peek(Token![=]) {
+ stream.parse::()?;
+
+ if stream.peek(Brace) {
+ // Braced expression values: `onclick={move |_| ...}`, `class={some_expr}`
+ let content: ParseBuffer;
+ braced!(content in stream);
+ let value = AttributeValue::parse(&content)?;
+ if !content.is_empty() {
+ return Err(content.error("unexpected tokens after attribute value"));
+ }
+ value
+ } else if stream.peek(LitStr)
+ || stream.peek(LitBool)
+ || stream.peek(LitFloat)
+ || stream.peek(LitInt)
+ {
+ // Literal values: `class="abc {def}"`, `width=100`
+ AttributeValue::AttrLiteral(stream.parse::()?)
+ } else {
+ return Err(stream.error(
+ "attribute values must be literals or expressions wrapped in braces (`attr={expr}`)",
+ ));
+ }
+ } else {
+ // Shorthand attributes: `
` is equivalent to `div { class }`
+ match &name {
+ AttributeName::BuiltIn(ident) => AttributeValue::Shorthand(ident.clone()),
+ _ => {
+ return Err(syn::Error::new(
+ name.span(),
+ "custom attributes must have a value",
+ ));
+ }
+ }
+ };
+
+ let mut attribute = Attribute::from_raw(name, value);
+
+ // Attributes in tags don't have commas, but stray ones are accepted for
+ // compatibility with the regular syntax
+ attribute.comma = stream.parse::().ok();
+
+ attributes.push(attribute);
+ }
+
+ Ok((attributes, spreads))
+}
+
+/// Parse the end of an open tag (`>` or `/>`), children, and the closing tag if there is one
+///
+/// A brace token is synthesized from the span of the open tag's `>` so that consumers that expect
+/// a braced body (completion hints, autofmt, ...) treat the tag as a complete node.
+fn parse_tag_end_and_children(
+ stream: ParseStream,
+ name: &str,
+ parse_close_name: impl FnOnce(ParseStream) -> syn::Result<()>,
+) -> syn::Result<(Brace, Vec)> {
+ // Self-closing tag: ``
+ if stream.peek(Token![/]) {
+ stream.parse::()?;
+ let gt = stream.parse::]>()?;
+ return Ok((synthetic_brace(gt), Vec::new()));
+ }
+
+ let gt = stream.parse::]>()?;
+
+ let mut children = Vec::new();
+ loop {
+ if stream.peek(Token![<]) && stream.peek2(Token![/]) {
+ break;
+ }
+
+ if stream.is_empty() {
+ return Err(syn::Error::new(
+ gt.span(),
+ format!("missing closing tag `{name}>`"),
+ ));
+ }
+
+ // Children of JSX tags are regular body nodes, so both syntaxes can be mixed freely
+ children.push(stream.parse::()?);
+ }
+
+ stream.parse::()?;
+ stream.parse::()?;
+ parse_close_name(stream)?;
+ stream.parse::]>()?;
+
+ Ok((synthetic_brace(gt), children))
+}
+
+fn synthetic_brace(gt: Token![>]) -> Brace {
+ let mut group = Group::new(Delimiter::Brace, TokenStream2::new());
+ group.set_span(gt.span());
+ Brace {
+ span: group.delim_span(),
+ }
+}
+
+fn path_to_string(path: &syn::Path) -> String {
+ use quote::ToTokens;
+ let mut name = path.to_token_stream().to_string();
+ name.retain(|c| !c.is_whitespace());
+ name
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use quote::quote;
+
+ fn parse(input: proc_macro2::TokenStream) -> BodyNode {
+ syn::parse2::(input).unwrap()
+ }
+
+ #[test]
+ fn parses_basic_element() {
+ let node = parse(quote! {