diff --git a/Cargo.lock b/Cargo.lock index 1cddc00..fc390fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,6 +260,8 @@ dependencies = [ "eyre", "git2", "icondata", + "magic", + "tailwind_fuse", "tokio", "tower-http", "tracing", @@ -268,6 +270,41 @@ dependencies = [ "vespid", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "derive-where" version = "1.2.7" @@ -654,6 +691,12 @@ dependencies = [ "syn", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -780,6 +823,16 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "magic" +version = "0.1.0" +dependencies = [ + "icondata", + "icondata_core", + "tailwind_fuse", + "vespid", +] + [[package]] name = "matchers" version = "0.1.0" @@ -817,6 +870,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.4" @@ -846,6 +905,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1225,6 +1294,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.90" @@ -1271,6 +1346,28 @@ dependencies = [ "syn", ] +[[package]] +name = "tailwind_fuse" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9d32d52c3191836fe1858b6b38442d1e536eeb11883b2041e6db080a208c2d" +dependencies = [ + "nom", + "tailwind_fuse_macro", +] + +[[package]] +name = "tailwind_fuse_macro" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89fd8a13e8e105a886fe9d15aa60580602be9ee9a17235e552f19faa3d7834f4" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1606,7 +1703,6 @@ version = "0.1.0" dependencies = [ "axum", "html-escape", - "icondata_core", "serde", "thiserror 2.0.6", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 51052e2..b88c3e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,15 @@ [workspace] members = [ - ".", + ".", "magic", "vespid", "vespid/macros" ] [workspace.dependencies] vespid.path = "vespid" +magic.path = "magic" +icondata = { version = "0.5", default-features = false, features = ["lucide"] } +tailwind_fuse = { version = "0.3.1", features = ["variant"] } axum = "0.7" tokio = { version = "1", features = ["full"] } @@ -16,7 +19,8 @@ version = "0.1.0" edition = "2021" [dependencies] -vespid = { workspace = true, features = ["icons"] } +vespid.workspace = true +magic.workspace = true tokio.workspace = true axum.workspace = true eyre = "0.6.12" @@ -25,5 +29,6 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-error = "0.2.1" git2 = "0.19.0" -icondata = { version = "0.5", default-features = false, features = ["lucide"] } +icondata.workspace = true tower-http = { version = "0.6.2", features = ["full"] } +tailwind_fuse.workspace = true diff --git a/magic/Cargo.toml b/magic/Cargo.toml new file mode 100644 index 0000000..ce46dea --- /dev/null +++ b/magic/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "magic" +version = "0.1.0" +edition = "2024" + +[dependencies] +icondata_core = "0.1" +icondata.workspace = true +vespid.workspace = true +tailwind_fuse.workspace = true diff --git a/magic/src/button.rs b/magic/src/button.rs new file mode 100644 index 0000000..c0f69eb --- /dev/null +++ b/magic/src/button.rs @@ -0,0 +1,65 @@ +use tailwind_fuse::*; + +#[derive(TwClass)] +#[tw( + class = "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" +)] +pub struct Button { + pub variant: ButtonVariant, + pub size: ButtonSize, +} + +#[derive(TwVariant)] +pub enum ButtonVariant { + #[tw( + default, + class = "bg-primary text-primary-foreground hover:bg-primary/90" + )] + Primary, + #[tw( + class = "border-primary border bg-transparent text-foreground hover:bg-primary hover:text-primary-foreground" + )] + OutlinePrimary, + #[tw(class = "bg-secondary text-secondary-foreground hover:bg-secondary/90")] + Secondary, + #[tw(class = "bg-success text-success-foreground hover:bg-success/90")] + Success, + #[tw( + class = "border-success border bg-transparent text-foreground hover:bg-success hover:text-success-foreground" + )] + OutlineSuccess, + #[tw(class = "bg-destructive text-destructive-foreground hover:bg-destructive/90")] + Destructive, + #[tw( + class = "border-destructive border bg-transparent text-foreground hover:bg-destructive hover:text-destructive-foreground" + )] + OutlineDestructive, + #[tw( + class = "border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground" + )] + Outline, + #[tw( + class = "border border-input bg-transparent text-foreground hover:bg-primary hover:text-primary-foreground flex items-center gap-1" + )] + OutlineIcon, + #[tw(class = "hover:bg-accent hover:text-accent-foreground")] + Ghost, + #[tw(class = "bg-secondary/80 text-accent-foreground hover:text-accent-foreground")] + Link, + #[tw( + class = "bg-secondary/80 text-accent-foreground hover:text-accent-foreground flex items-center gap-1" + )] + Icon, +} + +#[derive(TwVariant)] +pub enum ButtonSize { + #[tw(default, class = "h-10 px-4 py-2")] + Default, + #[tw(class = "h-9 rounded-md px-3")] + Small, + #[tw(class = "h-11 rounded-md px-8")] + Large, + #[tw(class = "h-10 w-10")] + Icon, +} diff --git a/magic/src/card.rs b/magic/src/card.rs new file mode 100644 index 0000000..89b6779 --- /dev/null +++ b/magic/src/card.rs @@ -0,0 +1,64 @@ +use tailwind_fuse::tw_merge; +use vespid::{MaybeText, component, view}; + +pub const CARD: &str = "rounded-lg border bg-card text-card-foreground shadow-sm"; +pub const CARD_HEADER: &str = "flex flex-col gap-2 p-6"; +pub const CARD_TITLE: &str = "text-lg font-semibold leading-none tracking-tight"; +pub const CARD_DESCRIPTION: &str = "text-sm text-muted-foreground"; +pub const CARD_CONTENT: &str = "p-6 pt-0"; +pub const CARD_FOOTER: &str = "flex items-center p-6 pt-0"; + +#[component] +pub async fn Card( + #[builder(default, setter(into))] class: MaybeText, + #[builder(default, setter(into))] id: MaybeText, + children: String, +) -> String { + let class = tw_merge!(CARD, class.get().unwrap_or("")); + view! {
{children}
} +} + +#[component] +pub async fn CardHeader( + #[builder(default, setter(into))] class: MaybeText, + children: String, +) -> String { + let class = tw_merge!(CARD_HEADER, class.get().unwrap_or("")); + view! {
{children}
} +} + +#[component] +pub async fn CardTitle( + #[builder(default, setter(into))] class: MaybeText, + children: String, +) -> String { + let class = tw_merge!(CARD_TITLE, class.get().unwrap_or("")); + view! {
{children}
} +} + +#[component] +pub async fn CardDescription( + #[builder(default, setter(into))] class: MaybeText, + children: String, +) -> String { + let class = tw_merge!(CARD_DESCRIPTION, class.get().unwrap_or("")); + view! {
{children}
} +} + +#[component] +pub async fn CardContent( + #[builder(default, setter(into))] class: MaybeText, + children: String, +) -> String { + let class = tw_merge!(CARD_CONTENT, class.get().unwrap_or("")); + view! {
{children}
} +} + +#[component] +pub async fn CardFooter( + #[builder(default, setter(into))] class: MaybeText, + children: String, +) -> String { + let class = tw_merge!(CARD_FOOTER, class.get().unwrap_or("")); + view! {
{children}
} +} diff --git a/vespid/src/icons.rs b/magic/src/icons.rs similarity index 97% rename from vespid/src/icons.rs rename to magic/src/icons.rs index 2aa27f7..b675208 100644 --- a/vespid/src/icons.rs +++ b/magic/src/icons.rs @@ -1,9 +1,9 @@ #![allow(non_snake_case)] -use super::*; +use vespid::prelude::*; pub use icondata_core::Icon; -#[vespid_macros::component] +#[component] pub fn Icon( icon: Icon, #[builder(default, setter(into))] width: MaybeText, diff --git a/magic/src/lib.rs b/magic/src/lib.rs new file mode 100644 index 0000000..0459ddb --- /dev/null +++ b/magic/src/lib.rs @@ -0,0 +1,13 @@ +#![allow(non_snake_case)] + +pub mod button; +pub mod card; +pub mod icons; +pub mod spinner; + +pub mod prelude { + pub use tailwind_fuse::{IntoBuilder, IntoTailwindClass, tw_merge}; + pub use vespid::prelude::*; + + pub use crate::{button::*, card::*, icons::*, spinner::*}; +} diff --git a/magic/src/spinner.rs b/magic/src/spinner.rs new file mode 100644 index 0000000..5cfca58 --- /dev/null +++ b/magic/src/spinner.rs @@ -0,0 +1,12 @@ +use tailwind_fuse::tw_merge; +use vespid::{MaybeText, component, view}; + +#[component] +pub async fn Spinner(#[builder(default, setter(into))] class: MaybeText) -> String { + let class = tw_merge!( + "htmx-indicator animate-spin spinner h-12 w-12", + class.get().unwrap_or("") + ); + + view! { } +} diff --git a/src/main.rs b/src/main.rs index 27bb3e8..6842409 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,8 @@ use std::sync::{atomic::AtomicU64, Arc}; use axum::{response::Html, routing::get}; use tracing::level_filters::LevelFilter; use tracing_subscriber::layer::SubscriberExt; -use vespid::{axum::render, prelude::*}; +use magic::prelude::*; +use vespid::axum::render; #[component] fn Shell(children: String) -> String { @@ -22,7 +23,7 @@ fn Shell(children: String) -> String { - + {children} @@ -36,12 +37,8 @@ async fn index() -> Html {

"Hello to Crusto!"

"Index"

- - -
- -
-
+
+
} @@ -68,10 +65,14 @@ async fn main() -> eyre::Result<()> { let app = axum::Router::new().route("/", get(index)).route("/widget", get(|| async move { render(async move { view! { -
-

"Widget"

-

{amount_of_refreshes.fetch_add(1, std::sync::atomic::Ordering::Relaxed)}

-
+ + + "Widget" + + +

{amount_of_refreshes.fetch_add(1, std::sync::atomic::Ordering::Relaxed)} " refreshes"

+
+
} }).await })); diff --git a/vespid/Cargo.toml b/vespid/Cargo.toml index e290ca5..c3ce91b 100644 --- a/vespid/Cargo.toml +++ b/vespid/Cargo.toml @@ -10,10 +10,5 @@ tokio-util = { version = "0.7.13", features = ["rt"] } axum.workspace = true vespid_macros.path = "macros" typed-builder = "0.20.0" - -icondata_core = { version = "0.1", optional = true } serde = "1.0.215" thiserror = "2.0.6" - -[features] -icons = ["dep:icondata_core"] diff --git a/vespid/macros/src/component.rs b/vespid/macros/src/component.rs new file mode 100644 index 0000000..155510e --- /dev/null +++ b/vespid/macros/src/component.rs @@ -0,0 +1,163 @@ +use proc_macro::TokenStream; +use proc_macro_error2::{abort, OptionExt}; +use quote::{quote, ToTokens}; +use syn::{parse::Parse, parse_quote, punctuated::Punctuated, FnArg, ItemStruct, Token}; + +struct PropsStruct { + name: syn::Ident, + item: ItemStruct, +} + +impl Parse for PropsStruct { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let item = input.parse::()?; + let name = item.ident.clone(); + + Ok(PropsStruct { name, item }) + } +} + +impl ToTokens for PropsStruct { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let name = &self.name; + let item = &self.item; + + let builder_name = + syn::Ident::new(&format!("{}Builder", name), proc_macro2::Span::call_site()); + + tokens.extend(quote! { + #[derive(::vespid::typed_builder::TypedBuilder)] + #[builder(doc, crate_module_path=::vespid::typed_builder)] + #item + + impl ::vespid::support::Props for #name { + type Builder = #builder_name; + fn builder() -> Self::Builder { + #name::builder() + } + } + }); + + let has_attributes = item.fields.iter().any(|field| { + field + .ident + .as_ref() + .expect_or_abort("field.ident == None") + .to_string() + == "attributes" + }); + + if has_attributes { + tokens.extend(quote! { + impl #builder_name { + pub fn push_attr(mut self, attr: A) -> Self { + self.props.attributes.push_str(&format!("{} ", attr)); + self + } + } + }); + } + } +} + +fn props(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream { + let props = syn::parse2::(input).unwrap(); + quote! { #props }.to_token_stream() +} + +pub fn process(input: TokenStream) -> TokenStream { + let comp = syn::parse_macro_input!(input as ComponentFn); + quote! { #comp }.to_token_stream().into() +} + +struct ComponentFn { + item: syn::ItemFn, +} + +impl Parse for ComponentFn { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let item = input.parse::()?; + Ok(ComponentFn { item }) + } +} + +impl ToTokens for ComponentFn { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let item = &self.item; + let name = &item.sig.ident; + + let (defs, args) = match item.sig.inputs.len() { + 0 => { + // generate empty props + let props_name = + syn::Ident::new(&format!("{}Props", name), proc_macro2::Span::call_site()); + ( + props(quote! { + pub struct #props_name{} + }), + quote! { _props: #props_name }, + ) + } + // match if there is a single arg of type #nameProps + 1 if matches!(item.sig.inputs.first().unwrap(), syn::FnArg::Typed(arg) if matches!(arg.ty.as_ref(), syn::Type::Path(p) if p.path.segments.last().unwrap().ident.to_string() == format!("{}Props", name))) => + { + let props = item.sig.inputs.first().unwrap(); + (quote! {}, props.to_token_stream()) + } + _ => { + let field_defs = &item + .sig + .inputs + .clone() + .into_iter() + .map(|i| match i { + FnArg::Receiver(_) => { + abort!(i, "builders do not support self"); + } + FnArg::Typed(mut t) => { + if t.attrs.is_empty() { + t.attrs.push(parse_quote! { #[builder(setter(into))] }); + } + + t + } + }) + .collect::>(); + let field_names = item + .sig + .inputs + .iter() + .map(|i| match i { + FnArg::Receiver(_) => { + abort!(i, "builders do not support self"); + } + FnArg::Typed(t) => &t.pat, + }) + .collect::>(); + let props_name = + syn::Ident::new(&format!("{}Props", name), proc_macro2::Span::call_site()); + + ( + props(quote! { + pub struct #props_name { + #field_defs + } + }), + quote! { #props_name { #field_names }: #props_name }, + ) + } + }; + + let body = &item.block; + let output = &item.sig.output; + let vis = &item.vis; + + tokens.extend(quote! { + #defs + #[allow(non_snake_case)] + #vis async fn #name(#args) #output { + #body + } + }); + } +} diff --git a/vespid/macros/src/lib.rs b/vespid/macros/src/lib.rs index f4507e6..b2e4770 100644 --- a/vespid/macros/src/lib.rs +++ b/vespid/macros/src/lib.rs @@ -1,514 +1,19 @@ -use std::collections::HashSet; +mod component; +mod view; use proc_macro::TokenStream; -use proc_macro2_diagnostics::Diagnostic; -use proc_macro_error2::{abort, OptionExt}; -use quote::{quote, quote_spanned, ToTokens}; -use rstml::{ - node::{KeyedAttribute, Node, NodeAttribute, NodeElement, NodeName}, - Infallible, - Parser, - ParserConfig, -}; -use syn::{ - parse::Parse, - parse_quote, - punctuated::Punctuated, - spanned::Spanned, - Expr, - ExprLit, - FnArg, - ItemStruct, - Token, -}; #[proc_macro] pub fn view(tokens: TokenStream) -> TokenStream { - html_inner(tokens, false) + view::process(tokens, false) } #[proc_macro] pub fn view_docs(tokens: TokenStream) -> TokenStream { - html_inner(tokens, true) -} - -fn is_empty_element(name: &str) -> bool { - // https://developer.mozilla.org/en-US/docs/Glossary/Empty_element - match name { - "img" | "input" | "meta" | "link" | "hr" | "br" | "source" | "track" | "wbr" | "area" - | "base" | "col" | "embed" | "param" => true, - _ => false, - } -} - -fn empty_elements_set() -> HashSet<&'static str> { - [ - "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", - "source", "track", "wbr", - ] - .into_iter() - .collect() -} - -fn html_inner(tokens: TokenStream, ide_helper: bool) -> TokenStream { - let config = ParserConfig::new() - .recover_block(true) - .element_close_use_default_wildcard_ident(true) - .always_self_closed_elements(empty_elements_set()); - - let parser = Parser::new(config); - let (nodes, errors) = parser.parse_recoverable(tokens).split_vec(); - process_nodes(ide_helper, &nodes, errors).into() -} - -fn process_nodes<'n>( - ide_helper: bool, - nodes: &'n Vec, - errors: Vec, -) -> proc_macro2::TokenStream { - let WalkNodesOutput { - static_format: html_string, - values, - collected_elements: elements, - diagnostics, - } = walk_nodes(&nodes); - let docs = if ide_helper { - generate_tags_docs(elements) - } else { - vec![] - }; - let errors = errors - .into_iter() - .map(|e| e.emit_as_expr_tokens()) - .chain(diagnostics); - quote! { - { - // Make sure that "compile_error!(..);" can be used in this context. - #(#errors;)* - // Make sure that "enum x{};" and "let _x = crate::element;" can be used in this context - #(#docs;)* - format!(#html_string, #(vespid::FormatRender::new(#values)),*) - } - } -} - -fn generate_tags_docs(elements: Vec<&NodeName>) -> Vec { - // Mark some of elements as type, - // and other as elements as fn in crate::docs, - // to give an example how to link tag with docs. - let elements_as_type: HashSet<&'static str> = - vec!["html", "head", "meta", "link", "body", "div"] - .into_iter() - .collect(); - - elements - .into_iter() - .map(|e| { - if elements_as_type.contains(&*e.to_string()) { - let element = quote_spanned!(e.span() => enum); - quote!({#element X{}}) - } else { - // let _ = crate::docs::element; - let element = quote_spanned!(e.span() => element); - quote!(let _ = crate::docs::#element) - } - }) - .collect() -} - -#[derive(Default)] -struct WalkNodesOutput<'a> { - static_format: String, - // Use proc_macro2::TokenStream instead of syn::Expr - // to provide more errors to the end user. - values: Vec, - // Additional diagnostic messages. - diagnostics: Vec, - // Collect elements to provide semantic highlight based on element tag. - // No differences between open tag and closed tag. - // Also multiple tags with same name can be present, - // because we need to mark each of them. - collected_elements: Vec<&'a NodeName>, -} -impl<'a> WalkNodesOutput<'a> { - fn extend(&mut self, other: WalkNodesOutput<'a>) { - self.static_format.push_str(&other.static_format); - self.values.extend(other.values); - self.diagnostics.extend(other.diagnostics); - self.collected_elements.extend(other.collected_elements); - } -} - -fn walk_nodes<'a>(nodes: &'a Vec) -> WalkNodesOutput<'a> { - let mut out = WalkNodesOutput::default(); - - for node in nodes { - match node { - Node::Doctype(doctype) => { - let value = &doctype.value.to_token_stream_string(); - out.static_format.push_str(&format!("", value)); - } - Node::Element(element) => { - let name = element.name().to_string(); - - if !is_component_tag_name(&name) { - match element.name() { - NodeName::Block(block) => { - out.static_format.push_str("<{}"); - out.values.push(block.to_token_stream()); - } - _ => { - out.static_format.push_str(&format!("<{}", name)); - out.collected_elements.push(&element.open_tag.name); - if let Some(e) = &element.close_tag { - out.collected_elements.push(&e.name) - } - } - } - - // attributes - for attribute in element.attributes() { - match attribute { - NodeAttribute::Block(block) => { - // If the nodes parent is an attribute we prefix with whitespace - out.static_format.push(' '); - out.static_format.push_str("{}"); - out.values.push(block.to_token_stream()); - } - NodeAttribute::Attribute(attribute) => { - let (static_format, value) = walk_attribute(attribute); - out.static_format.push_str(&static_format); - if let Some(value) = value { - out.values.push(value); - } - } - } - } - // Ignore childs of special Empty elements - if is_empty_element(element.open_tag.name.to_string().as_str()) { - out.static_format.push_str(" />"); - if !element.children.is_empty() { - let warning = proc_macro2_diagnostics::Diagnostic::spanned( - element.open_tag.name.span(), - proc_macro2_diagnostics::Level::Warning, - "Element is processed as empty, and cannot have any child", - ); - out.diagnostics.push(warning.emit_as_expr_tokens()) - } - - continue; - } - out.static_format.push('>'); - - // children - let other_output = walk_nodes(&element.children); - out.extend(other_output); - - match element.name() { - NodeName::Block(block) => { - out.static_format.push_str(""); - out.values.push(block.to_token_stream()); - } - _ => { - out.static_format.push_str(&format!("", name)); - } - } - } else { - // custom elements - out.static_format.push_str("{}"); - out.values - .push(CustomElement::new(element).to_token_stream()); - } - } - Node::Text(text) => { - out.static_format.push_str(&text.value_string()); - } - Node::RawText(text) => { - out.static_format.push_str(&text.to_string_best()); - } - Node::Fragment(fragment) => { - let other_output = walk_nodes(&fragment.children); - out.extend(other_output) - } - Node::Comment(comment) => { - out.static_format.push_str(""); - out.values.push(comment.value.to_token_stream()); - } - Node::Block(block) => { - let block = block.try_block().unwrap(); - let stmts = &block.stmts; - out.static_format.push_str("{}"); - out.values.push(quote!(#(#stmts)*)); - } - Node::Custom(_) => {} - } - } - - out -} - -fn walk_attribute(attribute: &KeyedAttribute) -> (String, Option) { - let mut static_format = String::new(); - let mut format_value = None; - let key = match attribute.key.to_string().as_str() { - "as_" => "as".to_string(), - _ => attribute.key.to_string(), - }; - static_format.push_str(&format!(" {}", key)); - - match attribute.value() { - Some(Expr::Lit(ExprLit { - lit: syn::Lit::Str(value), - .. - })) => { - static_format.push_str(&format!( - r#"="{}""#, - html_escape::encode_unquoted_attribute(&value.value()) - )); - } - Some(Expr::Lit(ExprLit { - lit: syn::Lit::Bool(value), - .. - })) => { - static_format.push_str(&format!(r#"="{}""#, value.value())); - } - Some(Expr::Lit(ExprLit { - lit: syn::Lit::Int(value), - .. - })) => { - static_format.push_str(&format!(r#"="{}""#, value.token())); - } - Some(Expr::Lit(ExprLit { - lit: syn::Lit::Float(value), - .. - })) => { - static_format.push_str(&format!(r#"="{}""#, value.token())); - } - Some(value) => { - static_format.push_str(r#"="{}""#); - format_value = Some( - quote! {{ - ::vespid::EscapeAttribute::escape_attribute(&#value) - }} - .into_token_stream(), - ); - } - None => {} - } - - (static_format, format_value) -} - -fn is_component_tag_name(name: &str) -> bool { - name.starts_with(|c: char| c.is_ascii_uppercase()) -} - -struct CustomElement<'e> { - e: &'e NodeElement, -} - -impl<'e> CustomElement<'e> { - fn new(e: &'e NodeElement) -> Self { - CustomElement { e } - } -} - -impl<'e> ToTokens for CustomElement<'_> { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - let name = self.e.name(); - - let mut chain = vec![quote! { - ::vespid::support::props_builder(&#name) - }]; - - let children = &self.e.children; - if !children.is_empty() { - let c = process_nodes(false, children, vec![]); - chain.push(quote! { .children(#c) }); - } - - chain.push({ - self.e - .attributes() - .iter() - .map(|a| match a { - NodeAttribute::Block(block) => { - quote! { - .push_attr( - #[allow(unused_braces)] - #block - ) - } - } - NodeAttribute::Attribute(attribute) => { - let key = &attribute.key; - let value = attribute.value().unwrap(); - quote! { .#key(#value) } - } - }) - .collect::() - }); - - chain.push(quote! { .build() }); - - tokens.extend(quote! { - #name(#(#chain)*).await - }); - } -} - -fn props(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream { - let props = syn::parse2::(input).unwrap(); - quote! { #props }.to_token_stream() -} - -struct PropsStruct { - name: syn::Ident, - item: ItemStruct, -} - -impl Parse for PropsStruct { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let item = input.parse::()?; - let name = item.ident.clone(); - - Ok(PropsStruct { name, item }) - } -} - -impl ToTokens for PropsStruct { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - let name = &self.name; - let item = &self.item; - - let builder_name = - syn::Ident::new(&format!("{}Builder", name), proc_macro2::Span::call_site()); - - tokens.extend(quote! { - #[derive(::vespid::typed_builder::TypedBuilder)] - #[builder(doc, crate_module_path=::vespid::typed_builder)] - #item - - impl ::vespid::support::Props for #name { - type Builder = #builder_name; - fn builder() -> Self::Builder { - #name::builder() - } - } - }); - - let has_attributes = item - .fields - .iter() - .any(|field| field.ident.as_ref().expect_or_abort("field.ident == None").to_string() == "attributes"); - - if has_attributes { - tokens.extend(quote! { - impl #builder_name { - pub fn push_attr(mut self, attr: A) -> Self { - self.props.attributes.push_str(&format!("{} ", attr)); - self - } - } - }); - } - } + view::process(tokens, true) } #[proc_macro_attribute] -pub fn component(_attr: TokenStream, input: TokenStream) -> TokenStream { - let comp = syn::parse_macro_input!(input as ComponentFn); - quote! { #comp }.to_token_stream().into() -} - -struct ComponentFn { - item: syn::ItemFn, -} - -impl Parse for ComponentFn { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let item = input.parse::()?; - Ok(ComponentFn { item }) - } -} - -impl ToTokens for ComponentFn { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - let item = &self.item; - let name = &item.sig.ident; - - let (defs, args) = match item.sig.inputs.len() { - 0 => { - // generate empty props - let props_name = - syn::Ident::new(&format!("{}Props", name), proc_macro2::Span::call_site()); - ( - props(quote! { - pub struct #props_name{} - }), - quote! { _props: #props_name }, - ) - } - // match if there is a single arg of type #nameProps - 1 if matches!(item.sig.inputs.first().unwrap(), syn::FnArg::Typed(arg) if matches!(arg.ty.as_ref(), syn::Type::Path(p) if p.path.segments.last().unwrap().ident.to_string() == format!("{}Props", name))) => - { - let props = item.sig.inputs.first().unwrap(); - (quote! {}, props.to_token_stream()) - } - _ => { - let field_defs = &item - .sig - .inputs - .clone() - .into_iter() - .map(|i| match i { - FnArg::Receiver(_) => { - abort!(i, "builders do not support self"); - } - FnArg::Typed(mut t) => { - if t.attrs.is_empty() { - t.attrs.push(parse_quote! { #[builder(setter(into))] }); - } - - t - } - }) - .collect::>(); - let field_names = item - .sig - .inputs - .iter() - .map(|i| match i { - FnArg::Receiver(_) => { - abort!(i, "builders do not support self"); - } - FnArg::Typed(t) => &t.pat, - }) - .collect::>(); - let props_name = - syn::Ident::new(&format!("{}Props", name), proc_macro2::Span::call_site()); - - ( - props(quote! { - pub struct #props_name { - #field_defs - } - }), - quote! { #props_name { #field_names }: #props_name }, - ) - } - }; - - let body = &item.block; - let output = &item.sig.output; - let vis = &item.vis; - - tokens.extend(quote! { - #defs - #[allow(non_snake_case)] - #vis async fn #name(#args) #output { - #body - } - }); - } +pub fn component(_args: TokenStream, input: TokenStream) -> TokenStream { + component::process(input) } diff --git a/vespid/macros/src/view.rs b/vespid/macros/src/view.rs new file mode 100644 index 0000000..7a61292 --- /dev/null +++ b/vespid/macros/src/view.rs @@ -0,0 +1,338 @@ +use std::collections::HashSet; + +use proc_macro::TokenStream; +use proc_macro2_diagnostics::Diagnostic; +use quote::{quote, quote_spanned, ToTokens}; +use rstml::{ + node::{KeyedAttribute, Node, NodeAttribute, NodeElement, NodeName}, + Infallible, + Parser, + ParserConfig, +}; +use syn::{spanned::Spanned, Expr, ExprLit}; + +fn is_empty_element(name: &str) -> bool { + // https://developer.mozilla.org/en-US/docs/Glossary/Empty_element + match name { + "img" | "input" | "meta" | "link" | "hr" | "br" | "source" | "track" | "wbr" | "area" + | "base" | "col" | "embed" | "param" => true, + _ => false, + } +} + +fn empty_elements_set() -> HashSet<&'static str> { + [ + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", + "source", "track", "wbr", + ] + .into_iter() + .collect() +} + +pub fn process(tokens: TokenStream, ide_helper: bool) -> TokenStream { + let config = ParserConfig::new() + .recover_block(true) + .element_close_use_default_wildcard_ident(true) + .always_self_closed_elements(empty_elements_set()); + + let parser = Parser::new(config); + let (nodes, errors) = parser.parse_recoverable(tokens).split_vec(); + process_nodes(ide_helper, &nodes, errors).into() +} + +fn process_nodes<'n>( + ide_helper: bool, + nodes: &'n Vec, + errors: Vec, +) -> proc_macro2::TokenStream { + let WalkNodesOutput { + static_format: html_string, + values, + collected_elements: elements, + diagnostics, + } = walk_nodes(&nodes); + let docs = if ide_helper { + generate_tags_docs(elements) + } else { + vec![] + }; + let errors = errors + .into_iter() + .map(|e| e.emit_as_expr_tokens()) + .chain(diagnostics); + quote! { + { + // Make sure that "compile_error!(..);" can be used in this context. + #(#errors;)* + // Make sure that "enum x{};" and "let _x = crate::element;" can be used in this context + #(#docs;)* + format!(#html_string, #(vespid::FormatRender::new(#values)),*) + } + } +} + +fn generate_tags_docs(elements: Vec<&NodeName>) -> Vec { + // Mark some of elements as type, + // and other as elements as fn in crate::docs, + // to give an example how to link tag with docs. + let elements_as_type: HashSet<&'static str> = + vec!["html", "head", "meta", "link", "body", "div"] + .into_iter() + .collect(); + + elements + .into_iter() + .map(|e| { + if elements_as_type.contains(&*e.to_string()) { + let element = quote_spanned!(e.span() => enum); + quote!({#element X{}}) + } else { + // let _ = crate::docs::element; + let element = quote_spanned!(e.span() => element); + quote!(let _ = crate::docs::#element) + } + }) + .collect() +} + +#[derive(Default)] +struct WalkNodesOutput<'a> { + static_format: String, + // Use proc_macro2::TokenStream instead of syn::Expr + // to provide more errors to the end user. + values: Vec, + // Additional diagnostic messages. + diagnostics: Vec, + // Collect elements to provide semantic highlight based on element tag. + // No differences between open tag and closed tag. + // Also multiple tags with same name can be present, + // because we need to mark each of them. + collected_elements: Vec<&'a NodeName>, +} +impl<'a> WalkNodesOutput<'a> { + fn extend(&mut self, other: WalkNodesOutput<'a>) { + self.static_format.push_str(&other.static_format); + self.values.extend(other.values); + self.diagnostics.extend(other.diagnostics); + self.collected_elements.extend(other.collected_elements); + } +} + +fn walk_nodes<'a>(nodes: &'a Vec) -> WalkNodesOutput<'a> { + let mut out = WalkNodesOutput::default(); + + for node in nodes { + match node { + Node::Doctype(doctype) => { + let value = &doctype.value.to_token_stream_string(); + out.static_format.push_str(&format!("", value)); + } + Node::Element(element) => { + let name = element.name().to_string(); + + if !is_component_tag_name(&name) { + match element.name() { + NodeName::Block(block) => { + out.static_format.push_str("<{}"); + out.values.push(block.to_token_stream()); + } + _ => { + out.static_format.push_str(&format!("<{}", name)); + out.collected_elements.push(&element.open_tag.name); + if let Some(e) = &element.close_tag { + out.collected_elements.push(&e.name) + } + } + } + + // attributes + for attribute in element.attributes() { + match attribute { + NodeAttribute::Block(block) => { + // If the nodes parent is an attribute we prefix with whitespace + out.static_format.push(' '); + out.static_format.push_str("{}"); + out.values.push(block.to_token_stream()); + } + NodeAttribute::Attribute(attribute) => { + let (static_format, value) = walk_attribute(attribute); + out.static_format.push_str(&static_format); + if let Some(value) = value { + out.values.push(value); + } + } + } + } + // Ignore childs of special Empty elements + if is_empty_element(element.open_tag.name.to_string().as_str()) { + out.static_format.push_str(" />"); + if !element.children.is_empty() { + let warning = proc_macro2_diagnostics::Diagnostic::spanned( + element.open_tag.name.span(), + proc_macro2_diagnostics::Level::Warning, + "Element is processed as empty, and cannot have any child", + ); + out.diagnostics.push(warning.emit_as_expr_tokens()) + } + + continue; + } + out.static_format.push('>'); + + // children + let other_output = walk_nodes(&element.children); + out.extend(other_output); + + match element.name() { + NodeName::Block(block) => { + out.static_format.push_str(""); + out.values.push(block.to_token_stream()); + } + _ => { + out.static_format.push_str(&format!("", name)); + } + } + } else { + // custom elements + out.static_format.push_str("{}"); + out.values + .push(CustomElement::new(element).to_token_stream()); + } + } + Node::Text(text) => { + out.static_format.push_str(&text.value_string()); + } + Node::RawText(text) => { + out.static_format.push_str(&text.to_string_best()); + } + Node::Fragment(fragment) => { + let other_output = walk_nodes(&fragment.children); + out.extend(other_output) + } + Node::Comment(comment) => { + out.static_format.push_str(""); + out.values.push(comment.value.to_token_stream()); + } + Node::Block(block) => { + let block = block.try_block().unwrap(); + let stmts = &block.stmts; + out.static_format.push_str("{}"); + out.values.push(quote!(#(#stmts)*)); + } + Node::Custom(_) => {} + } + } + + out +} + +fn walk_attribute(attribute: &KeyedAttribute) -> (String, Option) { + let mut static_format = String::new(); + let mut format_value = None; + let key = match attribute.key.to_string().as_str() { + "as_" => "as".to_string(), + _ => attribute.key.to_string(), + }; + static_format.push_str(&format!(" {}", key)); + + match attribute.value() { + Some(Expr::Lit(ExprLit { + lit: syn::Lit::Str(value), + .. + })) => { + static_format.push_str(&format!( + r#"="{}""#, + html_escape::encode_unquoted_attribute(&value.value()) + )); + } + Some(Expr::Lit(ExprLit { + lit: syn::Lit::Bool(value), + .. + })) => { + static_format.push_str(&format!(r#"="{}""#, value.value())); + } + Some(Expr::Lit(ExprLit { + lit: syn::Lit::Int(value), + .. + })) => { + static_format.push_str(&format!(r#"="{}""#, value.token())); + } + Some(Expr::Lit(ExprLit { + lit: syn::Lit::Float(value), + .. + })) => { + static_format.push_str(&format!(r#"="{}""#, value.token())); + } + Some(value) => { + static_format.push_str(r#"="{}""#); + format_value = Some( + quote! {{ + ::vespid::EscapeAttribute::escape_attribute(&#value) + }} + .into_token_stream(), + ); + } + None => {} + } + + (static_format, format_value) +} + +fn is_component_tag_name(name: &str) -> bool { + // either module::Component or Component + name.chars().any(|c| c == ':' || c.is_ascii_uppercase()) +} + +struct CustomElement<'e> { + e: &'e NodeElement, +} + +impl<'e> CustomElement<'e> { + fn new(e: &'e NodeElement) -> Self { + CustomElement { e } + } +} + +impl<'e> ToTokens for CustomElement<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let name = self.e.name(); + + let mut chain = vec![quote! { + ::vespid::support::props_builder(&#name) + }]; + + let children = &self.e.children; + if !children.is_empty() { + let c = process_nodes(false, children, vec![]); + chain.push(quote! { .children(#c) }); + } + + chain.push({ + self.e + .attributes() + .iter() + .map(|a| match a { + NodeAttribute::Block(block) => { + quote! { + .push_attr( + #[allow(unused_braces)] + #block + ) + } + } + NodeAttribute::Attribute(attribute) => { + let key = &attribute.key; + let value = attribute.value().unwrap(); + quote! { .#key(#value) } + } + }) + .collect::() + }); + + chain.push(quote! { .build() }); + + tokens.extend(quote! { + #name(#(#chain)*).await + }); + } +} diff --git a/vespid/src/escape_attribute.rs b/vespid/src/escape_attribute.rs index 25548d1..d0040be 100644 --- a/vespid/src/escape_attribute.rs +++ b/vespid/src/escape_attribute.rs @@ -30,6 +30,16 @@ impl EscapeAttribute for &String { } } +impl EscapeAttribute for Option { + #[inline(always)] + fn escape_attribute(&self) -> Cow<'_, str> { + match self { + Some(inner) => inner.escape_attribute(), + None => Cow::Borrowed(""), + } + } +} + macro_rules! impl_escape_attribute_literal { ($($t:ty),*) => { $( diff --git a/vespid/src/lib.rs b/vespid/src/lib.rs index c18c19d..e1d7121 100644 --- a/vespid/src/lib.rs +++ b/vespid/src/lib.rs @@ -1,5 +1,3 @@ -extern crate self as vespid; - mod escape_attribute; pub use escape_attribute::*; @@ -27,13 +25,8 @@ pub extern crate html_escape; #[doc(hidden)] pub extern crate typed_builder; -#[cfg(feature = "icons")] -pub mod icons; - pub mod prelude { pub use {html_escape, typed_builder}; pub use vespid_macros::*; pub use crate::{context::*, render::Render, render_adapter::*, text::*}; - #[cfg(feature = "icons")] - pub use crate::icons::*; }