Added a few minky magic components
This commit is contained in:
parent
4939d5a58f
commit
54e47e68f7
15 changed files with 801 additions and 531 deletions
98
Cargo.lock
generated
98
Cargo.lock
generated
|
@ -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",
|
||||||
|
|
11
Cargo.toml
11
Cargo.toml
|
@ -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
10
magic/Cargo.toml
Normal 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
65
magic/src/button.rs
Normal 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
64
magic/src/card.rs
Normal 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> }
|
||||||
|
}
|
|
@ -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
13
magic/src/lib.rs
Normal 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
12
magic/src/spinner.rs
Normal 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 /> }
|
||||||
|
}
|
25
src/main.rs
25
src/main.rs
|
@ -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
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -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"]
|
|
||||||
|
|
163
vespid/macros/src/component.rs
Normal file
163
vespid/macros/src/component.rs
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
338
vespid/macros/src/view.rs
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),*) => {
|
||||||
$(
|
$(
|
||||||
|
|
|
@ -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::*;
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue