Added a few minky magic components

This commit is contained in:
Borodinov Ilya 2024-12-09 14:55:19 +03:00
parent 4939d5a58f
commit 54e47e68f7
Signed by: noth
GPG key ID: 75503B2EF596D1BD
15 changed files with 801 additions and 531 deletions

98
Cargo.lock generated
View file

@ -260,6 +260,8 @@ dependencies = [
"eyre", "eyre",
"git2", "git2",
"icondata", "icondata",
"magic",
"tailwind_fuse",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",
@ -268,6 +270,41 @@ dependencies = [
"vespid", "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]] [[package]]
name = "derive-where" name = "derive-where"
version = "1.2.7" version = "1.2.7"
@ -654,6 +691,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.0.3" version = "1.0.3"
@ -780,6 +823,16 @@ version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "magic"
version = "0.1.0"
dependencies = [
"icondata",
"icondata_core",
"tailwind_fuse",
"vespid",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.1.0" version = "0.1.0"
@ -817,6 +870,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.7.4" version = "0.7.4"
@ -846,6 +905,16 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@ -1225,6 +1294,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.90" version = "2.0.90"
@ -1271,6 +1346,28 @@ dependencies = [
"syn", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -1606,7 +1703,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"html-escape", "html-escape",
"icondata_core",
"serde", "serde",
"thiserror 2.0.6", "thiserror 2.0.6",
"tokio", "tokio",

View file

@ -1,12 +1,15 @@
[workspace] [workspace]
members = [ members = [
".", ".", "magic",
"vespid", "vespid",
"vespid/macros" "vespid/macros"
] ]
[workspace.dependencies] [workspace.dependencies]
vespid.path = "vespid" 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" axum = "0.7"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@ -16,7 +19,8 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
vespid = { workspace = true, features = ["icons"] } vespid.workspace = true
magic.workspace = true
tokio.workspace = true tokio.workspace = true
axum.workspace = true axum.workspace = true
eyre = "0.6.12" eyre = "0.6.12"
@ -25,5 +29,6 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tracing-error = "0.2.1" tracing-error = "0.2.1"
git2 = "0.19.0" git2 = "0.19.0"
icondata = { version = "0.5", default-features = false, features = ["lucide"] } icondata.workspace = true
tower-http = { version = "0.6.2", features = ["full"] } tower-http = { version = "0.6.2", features = ["full"] }
tailwind_fuse.workspace = true

10
magic/Cargo.toml Normal file
View file

@ -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

65
magic/src/button.rs Normal file
View file

@ -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,
}

64
magic/src/card.rs Normal file
View file

@ -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! { <div class={class} id=id.get()>{children}</div> }
}
#[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! { <div class={class}>{children}</div> }
}
#[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! { <div class={class}>{children}</div> }
}
#[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! { <div class={class}>{children}</div> }
}
#[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! { <div class={class}>{children}</div> }
}
#[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! { <div class={class}>{children}</div> }
}

View file

@ -1,9 +1,9 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
use super::*; use vespid::prelude::*;
pub use icondata_core::Icon; pub use icondata_core::Icon;
#[vespid_macros::component] #[component]
pub fn Icon( pub fn Icon(
icon: Icon, icon: Icon,
#[builder(default, setter(into))] width: MaybeText, #[builder(default, setter(into))] width: MaybeText,

13
magic/src/lib.rs Normal file
View file

@ -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::*};
}

12
magic/src/spinner.rs Normal file
View file

@ -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! { <crate::icons::Icon icon=icondata::LuLoader2 class=class /> }
}

View file

@ -8,7 +8,8 @@ use std::sync::{atomic::AtomicU64, Arc};
use axum::{response::Html, routing::get}; use axum::{response::Html, routing::get};
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use vespid::{axum::render, prelude::*}; use magic::prelude::*;
use vespid::axum::render;
#[component] #[component]
fn Shell(children: String) -> String { fn Shell(children: String) -> String {
@ -22,7 +23,7 @@ fn Shell(children: String) -> String {
<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script> <script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
<style>{include_str!("../target/app.css")}</style> <style>{include_str!("../target/app.css")}</style>
</head> </head>
<body> <body class="flex flex-col">
{children} {children}
</body> </body>
</html> </html>
@ -36,12 +37,8 @@ async fn index() -> Html<String> {
<h1>"Hello to Crusto!"</h1> <h1>"Hello to Crusto!"</h1>
<p>"Index"</p> <p>"Index"</p>
<button hx-get="/widget" hx-swap="outerHTML" hx-target="#widget" hx-trigger="load delay:1s, click" hx-indicator="#widget_container > .spinner">"Get widget"</button> <div id="widget" class="flex flex-col items-center justify-center" hx-get="/widget" hx-swap="outerHTML" hx-trigger="load" hx-indicator="#widget > .spinner">
<Spinner />
<div id="widget_container" class="flex flex-col items-center justify-center">
<Icon icon=icondata::LuLoader2 class="animate-spin spinner htmx-indicator" />
<div id="widget">
</div>
</div> </div>
</Shell> </Shell>
} }
@ -68,10 +65,14 @@ async fn main() -> eyre::Result<()> {
let app = axum::Router::new().route("/", get(index)).route("/widget", get(|| async move { let app = axum::Router::new().route("/", get(index)).route("/widget", get(|| async move {
render(async move { render(async move {
view! { view! {
<div style="background-color: red; color: white; padding: 10px; border-radius: 5px;" id="widget"> <Card id="widget" class="w-64 flex flex-col">
<h2>"Widget"</h2> <CardHeader>
<p>{amount_of_refreshes.fetch_add(1, std::sync::atomic::Ordering::Relaxed)}</p> <CardTitle>"Widget"</CardTitle>
</div> </CardHeader>
<CardContent>
<p>{amount_of_refreshes.fetch_add(1, std::sync::atomic::Ordering::Relaxed)} " refreshes"</p>
</CardContent>
</Card>
} }
}).await }).await
})); }));

View file

@ -10,10 +10,5 @@ tokio-util = { version = "0.7.13", features = ["rt"] }
axum.workspace = true axum.workspace = true
vespid_macros.path = "macros" vespid_macros.path = "macros"
typed-builder = "0.20.0" typed-builder = "0.20.0"
icondata_core = { version = "0.1", optional = true }
serde = "1.0.215" serde = "1.0.215"
thiserror = "2.0.6" thiserror = "2.0.6"
[features]
icons = ["dep:icondata_core"]

View file

@ -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<Self> {
let item = input.parse::<ItemStruct>()?;
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<A: std::fmt::Display>(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::<PropsStruct>(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<Self> {
let item = input.parse::<syn::ItemFn>()?;
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::<Punctuated<_, Token![,]>>();
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::<Punctuated<_, Token![,]>>();
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
}
});
}
}

View file

@ -1,514 +1,19 @@
use std::collections::HashSet; mod component;
mod view;
use proc_macro::TokenStream; 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] #[proc_macro]
pub fn view(tokens: TokenStream) -> TokenStream { pub fn view(tokens: TokenStream) -> TokenStream {
html_inner(tokens, false) view::process(tokens, false)
} }
#[proc_macro] #[proc_macro]
pub fn view_docs(tokens: TokenStream) -> TokenStream { pub fn view_docs(tokens: TokenStream) -> TokenStream {
html_inner(tokens, true) view::process(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<Node>,
errors: Vec<Diagnostic>,
) -> 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<proc_macro2::TokenStream> {
// 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<proc_macro2::TokenStream>,
// Additional diagnostic messages.
diagnostics: Vec<proc_macro2::TokenStream>,
// 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<Node>) -> 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!("<!DOCTYPE {}>", 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<proc_macro2::TokenStream>) {
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<Infallible>,
}
impl<'e> CustomElement<'e> {
fn new(e: &'e NodeElement<Infallible>) -> 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::<proc_macro2::TokenStream>()
});
chain.push(quote! { .build() });
tokens.extend(quote! {
#name(#(#chain)*).await
});
}
}
fn props(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
let props = syn::parse2::<PropsStruct>(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<Self> {
let item = input.parse::<ItemStruct>()?;
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<A: std::fmt::Display>(mut self, attr: A) -> Self {
self.props.attributes.push_str(&format!("{} ", attr));
self
}
}
});
}
}
} }
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn component(_attr: TokenStream, input: TokenStream) -> TokenStream { pub fn component(_args: TokenStream, input: TokenStream) -> TokenStream {
let comp = syn::parse_macro_input!(input as ComponentFn); component::process(input)
quote! { #comp }.to_token_stream().into()
}
struct ComponentFn {
item: syn::ItemFn,
}
impl Parse for ComponentFn {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let item = input.parse::<syn::ItemFn>()?;
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::<Punctuated<_, Token![,]>>();
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::<Punctuated<_, Token![,]>>();
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
}
});
}
} }

338
vespid/macros/src/view.rs Normal file
View file

@ -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<Node>,
errors: Vec<Diagnostic>,
) -> 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<proc_macro2::TokenStream> {
// 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<proc_macro2::TokenStream>,
// Additional diagnostic messages.
diagnostics: Vec<proc_macro2::TokenStream>,
// 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<Node>) -> 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!("<!DOCTYPE {}>", 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<proc_macro2::TokenStream>) {
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<Infallible>,
}
impl<'e> CustomElement<'e> {
fn new(e: &'e NodeElement<Infallible>) -> 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::<proc_macro2::TokenStream>()
});
chain.push(quote! { .build() });
tokens.extend(quote! {
#name(#(#chain)*).await
});
}
}

View file

@ -30,6 +30,16 @@ impl EscapeAttribute for &String {
} }
} }
impl<T: EscapeAttribute> EscapeAttribute for Option<T> {
#[inline(always)]
fn escape_attribute(&self) -> Cow<'_, str> {
match self {
Some(inner) => inner.escape_attribute(),
None => Cow::Borrowed(""),
}
}
}
macro_rules! impl_escape_attribute_literal { macro_rules! impl_escape_attribute_literal {
($($t:ty),*) => { ($($t:ty),*) => {
$( $(

View file

@ -1,5 +1,3 @@
extern crate self as vespid;
mod escape_attribute; mod escape_attribute;
pub use escape_attribute::*; pub use escape_attribute::*;
@ -27,13 +25,8 @@ pub extern crate html_escape;
#[doc(hidden)] #[doc(hidden)]
pub extern crate typed_builder; pub extern crate typed_builder;
#[cfg(feature = "icons")]
pub mod icons;
pub mod prelude { pub mod prelude {
pub use {html_escape, typed_builder}; pub use {html_escape, typed_builder};
pub use vespid_macros::*; pub use vespid_macros::*;
pub use crate::{context::*, render::Render, render_adapter::*, text::*}; pub use crate::{context::*, render::Render, render_adapter::*, text::*};
#[cfg(feature = "icons")]
pub use crate::icons::*;
} }