diff --git a/.gitignore b/.gitignore index 47e996a..3923422 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .devenv .direnv +/node_modules diff --git a/Cargo.lock b/Cargo.lock index d34b4a6..1cddc00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -26,6 +32,37 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "async-compression" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df895a515f70646414f4b45c0b79082783b80552b373a68283012928df56f522" +dependencies = [ + "brotli", + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "zstd", + "zstd-safe", +] + [[package]] name = "async-trait" version = "0.1.83" @@ -108,17 +145,44 @@ dependencies = [ "cc", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.7.4", "object", "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bytes" version = "1.9.0" @@ -178,6 +242,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "crusto" version = "0.1.0" @@ -186,7 +259,9 @@ dependencies = [ "color-eyre", "eyre", "git2", + "icondata", "tokio", + "tower-http", "tracing", "tracing-error", "tracing-subscriber", @@ -225,6 +300,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide 0.8.0", +] + [[package]] name = "fnv" version = "1.0.7" @@ -292,6 +377,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "gimli" version = "0.28.1" @@ -362,6 +458,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.9.5" @@ -409,6 +511,31 @@ dependencies = [ "tower-service", ] +[[package]] +name = "icondata" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805a2a031c06a768407b774240192b0ad82ddb94554bb5b52053453f2f6b0bf1" +dependencies = [ + "icondata_core", + "icondata_lu", +] + +[[package]] +name = "icondata_core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c97be924215abd5e630d84e95a47c710138a6559b4c55039f4f33aa897fa859" + +[[package]] +name = "icondata_lu" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d552c45cc3ab1d1bf88cc0201004eb92418141e5454e9e0e46c4b4a4faf66248" +dependencies = [ + "icondata_core", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -554,6 +681,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +[[package]] +name = "iri-string" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0f0a572e8ffe56e2ff4f769f32ffe919282c3916799f8b68688b6030063bea" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itoa" version = "1.0.14" @@ -670,6 +807,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.7.4" @@ -679,6 +826,15 @@ dependencies = [ "adler", ] +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.0.3" @@ -933,7 +1089,7 @@ dependencies = [ "quote", "syn", "syn_derive", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1121,7 +1277,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec2a1820ebd077e2b90c4df007bebf344cd394098a13c563957d0afc83ea47" +dependencies = [ + "thiserror-impl 2.0.6", ] [[package]] @@ -1135,6 +1300,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65750cab40f4ff1929fb1ba509e9914eb756131cef4210da8d5d700d26f6312" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -1215,6 +1391,37 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +dependencies = [ + "async-compression", + "base64", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "uuid", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1319,6 +1526,12 @@ dependencies = [ "syn", ] +[[package]] +name = "unicase" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" + [[package]] name = "unicode-ident" version = "1.0.14" @@ -1360,6 +1573,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom", +] + [[package]] name = "valuable" version = "0.1.0" @@ -1384,6 +1606,9 @@ version = "0.1.0" dependencies = [ "axum", "html-escape", + "icondata_core", + "serde", + "thiserror 2.0.6", "tokio", "tokio-util", "typed-builder", @@ -1589,3 +1814,31 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zstd" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.13+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 3b1cffe..51052e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ version = "0.1.0" edition = "2021" [dependencies] -vespid.workspace = true +vespid = { workspace = true, features = ["icons"] } tokio.workspace = true axum.workspace = true eyre = "0.6.12" @@ -25,3 +25,5 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-error = "0.2.1" git2 = "0.19.0" +icondata = { version = "0.5", default-features = false, features = ["lucide"] } +tower-http = { version = "0.6.2", features = ["full"] } diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..cd083ad Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..fcb0e73 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "crusto", + "devDependencies": { + "@catppuccin/tailwindcss": "^0.1.6", + "@tailwindcss/cli": "^4.0.0-beta.6", + "tailwindcss": "^4.0.0-beta.6" + }, + "scripts": { + "tailwind:build": "tailwindcss -i ./src/app.css -o ./target/app.css" + } +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..12caca9 --- /dev/null +++ b/src/app.css @@ -0,0 +1,101 @@ +@import "tailwindcss"; +@config "../tailwind.config.ts"; + +html { + @apply w-full h-full; + font-size: 100%; +} + +select, +textarea, +input, +button { + font: inherit; +} + +body { + @apply bg-background text-foreground shadow-accent w-full h-full; + cursor: auto !important; + font-feature-settings: + "rlig" 1, + "calt" 1; +} + +* { + @apply border-border; +} + +@layer base { + :root { + --radius: 0.5rem; + } +} + +@layer utilities { + .hstack { + @apply flex flex-row items-center; + } + + .vstack { + @apply flex flex-col; + } + + .fullcenter { + @apply items-center justify-center; + } + + .ty ol, + .ty ul { + @apply my-6 ml-6 list-disc [&>li]:mt-2; + } + .ty h1 { + @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; + } + .ty h2 { + @apply mt-10 scroll-m-20 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0; + } + .ty h3 { + @apply scroll-m-20 text-2xl font-semibold tracking-tight; + } + .ty h4 { + @apply scroll-m-20 text-xl font-semibold tracking-tight mt-2; + } + .ty p { + @apply leading-7 [&:not(:first-child)]:mt-4; + } + .ty blockquote { + @apply mt-6 border-l-2 pl-6 italic; + } + .ty code { + @apply relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold; + } + .ty hr { + @apply my-6 border-border/60 rounded-lg; + } + .ty a:not([m-button]) { + @apply text-info underline; + } + + .lead { + @apply text-xl text-muted-foreground; + } + .large { + @apply text-lg font-semibold; + } + .small { + @apply text-sm font-medium leading-none; + } + .muted { + @apply text-sm text-muted-foreground; + } + + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } +} diff --git a/src/main.rs b/src/main.rs index 6c23977..27bb3e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ #![allow(non_snake_case)] + #[macro_use] extern crate tracing; @@ -7,7 +8,7 @@ use std::sync::{atomic::AtomicU64, Arc}; use axum::{response::Html, routing::get}; use tracing::level_filters::LevelFilter; use tracing_subscriber::layer::SubscriberExt; -use vespid::{axum_compat::render, *}; +use vespid::{axum::render, prelude::*}; #[component] fn Shell(children: String) -> String { @@ -19,6 +20,7 @@ fn Shell(children: String) -> String { Crusto + {children} @@ -34,9 +36,12 @@ async fn index() -> Html {

"Hello to Crusto!"

"Index"

- + -
+
+ +
+
} @@ -59,6 +64,7 @@ async fn main() -> eyre::Result<()> { )?; let amount_of_refreshes = Arc::new(AtomicU64::new(0)); + let app = axum::Router::new().route("/", get(index)).route("/widget", get(|| async move { render(async move { view! { @@ -69,8 +75,10 @@ async fn main() -> eyre::Result<()> { } }).await })); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; info!("listening on {}", listener.local_addr()?); axum::serve(listener, app).await?; + Ok(()) } diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..e90b4cc --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,125 @@ +import catppuccin from '@catppuccin/tailwindcss'; +import type { Config } from 'tailwindcss'; +import { fontFamily } from 'tailwindcss/defaultTheme'; + +export default { + content: [ + './src/**/*.{html,ts,scss,css}', + '../magic/src/**/*.{html,ts,scss,css}', + ], + darkMode: ['selector', ':not(.latte)'], + theme: { + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px', + }, + }, + extend: { + /** + --background: var(--ctp-base); + --foreground: var(--ctp-text); + + --muted: var(--ctp-overlay0); + --muted-foreground: var(--ctp-overlay1); + + --popover: var(--ctp-mantle); + --popover-foreground: var(--ctp-text); + + --border: var(--ctp-surface1); + --input: var(--ctp-surface1); + + --card: var(--ctp-mantle); + --card-foreground: var(--ctp-text); + + --primary: var(--ctp-mauve); + --primary-foreground: var(--ctp-base); + + --secondary: var(--ctp-surface0); + --secondary-foreground: var(--ctp-text); + + --accent: var(--ctp-base); + --accent-foreground: var(--ctp-text); + + --success: var(--ctp-green); + --success-foreground: var(--ctp-crust); + + --info: var(--ctp-blue); + --info-foreground: var(--ctp-crust); + + --warning: var(--ctp-peach); + --warning-foreground: var(--ctp-crust); + + --destructive: var(--ctp-red); + --destructive-foreground: var(--ctp-crust); + + --ring: var(--ctp-text); + */ + colors: { + border: 'rgba(var(--ctp-overlay1), )', + input: 'rgba(var(--ctp-surface2), )', + ring: 'rgba(var(--ctp-text), )', + background: 'rgba(var(--ctp-base), )', + foreground: 'rgba(var(--ctp-text), )', + primary: { + DEFAULT: 'rgba(var(--ctp-lavender), )', + foreground: 'rgba(var(--ctp-base), )', + }, + secondary: { + DEFAULT: 'rgba(var(--ctp-surface0), )', + foreground: 'rgba(var(--ctp-text), )', + }, + destructive: { + DEFAULT: 'rgba(var(--ctp-red), )', + foreground: 'rgba(var(--ctp-crust), )', + }, + warning: { + DEFAULT: 'rgba(var(--ctp-peach), )', + foreground: 'rgba(var(--ctp-crust), )', + }, + info: { + DEFAULT: 'rgba(var(--ctp-blue), )', + foreground: 'rgba(var(--info-foreground), )', + }, + success: { + DEFAULT: 'rgba(var(--ctp-green), )', + foreground: 'rgba(var(--ctp-crust), )', + }, + muted: { + DEFAULT: 'rgba(var(--ctp-overlay0), )', + foreground: 'rgba(var(--ctp-overlay1), )', + }, + invariant: { + DEFAULT: 'rgba(var(--ctp-subtext0), )', + foreground: 'rgba(var(--ctp-mantle), )', + }, + accent: { + DEFAULT: 'rgba(var(--ctp-base), )', + foreground: 'rgba(var(--ctp-text), )', + }, + popover: { + DEFAULT: 'rgba(var(--ctp-mantle), )', + foreground: 'rgba(var(--ctp-text), )', + }, + card: { + DEFAULT: 'rgba(var(--ctp-mantle), )', + foreground: 'rgba(var(--ctp-text), )', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + fontFamily: { + sans: ['Inter', ...fontFamily.sans], + }, + }, + }, + plugins: [ + catppuccin({ + defaultFlavour: 'mocha', + }), + ], +} satisfies Config; diff --git a/vespid/Cargo.toml b/vespid/Cargo.toml index a37dba5..e290ca5 100644 --- a/vespid/Cargo.toml +++ b/vespid/Cargo.toml @@ -10,3 +10,10 @@ tokio-util = { version = "0.7.13", features = ["rt"] } axum.workspace = true vespid_macros.path = "macros" typed-builder = "0.20.0" + +icondata_core = { version = "0.1", optional = true } +serde = "1.0.215" +thiserror = "2.0.6" + +[features] +icons = ["dep:icondata_core"] diff --git a/vespid/macros/src/lib.rs b/vespid/macros/src/lib.rs index 37812da..f4507e6 100644 --- a/vespid/macros/src/lib.rs +++ b/vespid/macros/src/lib.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; 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}, @@ -287,7 +288,6 @@ fn walk_attribute(attribute: &KeyedAttribute) -> (String, Option { - panic!("receiver arguments unsupported"); + abort!(i, "builders do not support self"); } FnArg::Typed(mut t) => { if t.attrs.is_empty() { @@ -480,7 +480,7 @@ impl ToTokens for ComponentFn { .iter() .map(|i| match i { FnArg::Receiver(_) => { - panic!("receiver arguments unsupported"); + abort!(i, "builders do not support self"); } FnArg::Typed(t) => &t.pat, }) diff --git a/vespid/src/axum_compat.rs b/vespid/src/axum.rs similarity index 100% rename from vespid/src/axum_compat.rs rename to vespid/src/axum.rs diff --git a/vespid/src/icons.rs b/vespid/src/icons.rs new file mode 100644 index 0000000..2aa27f7 --- /dev/null +++ b/vespid/src/icons.rs @@ -0,0 +1,28 @@ +#![allow(non_snake_case)] + +use super::*; +pub use icondata_core::Icon; + +#[vespid_macros::component] +pub fn Icon( + icon: Icon, + #[builder(default, setter(into))] width: MaybeText, + #[builder(default, setter(into))] height: MaybeText, + #[builder(default, setter(into))] class: MaybeText, + #[builder(default, setter(into))] style: MaybeText, +) -> String { + let width = format!("width=\"{}\"", width.get().unwrap_or("1em")); + let height = format!("height=\"{}\"", height.get().unwrap_or("1em")); + let class = class.get().map_or_else(String::new, |class| format!("class=\"{}\"", class)); + let style = style.get().map_or_else(String::new, |style| format!("style=\"{}\"", style)); + let x = icon.x.map_or_else(String::new, |x| format!("x=\"{}\"", x)); + let y = icon.y.map_or_else(String::new, |y| format!("y=\"{}\"", y)); + let viewbox = icon.view_box.map_or_else(String::new, |viewbox| format!("viewBox=\"{}\"", viewbox)); + let stroke_linecap = icon.stroke_linecap.map_or_else(String::new, |stroke_linecap| format!("stroke-linecap=\"{}\"", stroke_linecap)); + let stroke_linejoin = icon.stroke_linejoin.map_or_else(String::new, |stroke_linejoin| format!("stroke-linejoin=\"{}\"", stroke_linejoin)); + let stroke_width = icon.stroke_width.map_or_else(String::new, |stroke_width| format!("stroke-width=\"{}\"", stroke_width)); + let stroke = icon.stroke.map_or_else(String::new, |stroke| format!("stroke=\"{}\"", stroke)); + let fill = format!("fill=\"{}\"", icon.fill.unwrap_or("currentColor")); + let data = icon.data; + format!("{data}") +} diff --git a/vespid/src/lib.rs b/vespid/src/lib.rs index 4d48e50..c18c19d 100644 --- a/vespid/src/lib.rs +++ b/vespid/src/lib.rs @@ -1,3 +1,5 @@ +extern crate self as vespid; + mod escape_attribute; pub use escape_attribute::*; @@ -14,7 +16,9 @@ pub use render_adapter::*; mod context; pub use context::*; -pub mod axum_compat; +mod text; +pub use text::*; +pub mod axum; pub use vespid_macros::*; @@ -22,3 +26,14 @@ pub use vespid_macros::*; pub extern crate html_escape; #[doc(hidden)] pub extern crate typed_builder; + +#[cfg(feature = "icons")] +pub mod icons; + +pub mod prelude { + pub use {html_escape, typed_builder}; + pub use vespid_macros::*; + pub use crate::{context::*, render::Render, render_adapter::*, text::*}; + #[cfg(feature = "icons")] + pub use crate::icons::*; +} diff --git a/vespid/src/text.rs b/vespid/src/text.rs new file mode 100644 index 0000000..8045bd7 --- /dev/null +++ b/vespid/src/text.rs @@ -0,0 +1,748 @@ +//! This module contains the `Oco` (Owned Clones Once) smart pointer, +//! which is used to store immutable references to values. +//! This is useful for storing, for example, strings. + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::{ + borrow::{Borrow, Cow}, + ffi::{CStr, OsStr}, + fmt, + hash::Hash, + ops::{Add, Deref}, + path::Path, + sync::Arc, +}; + +pub type MaybeText = OptionText<'static>; + +pub enum OptionText<'a> { + Text(Oco<'a, str>), + None +} + +impl<'a> OptionText<'a> { + pub fn get(&self) -> Option<&str> { + match self { + OptionText::Text(text) => Some(text.as_ref()), + OptionText::None => None + } + } +} + +impl<'a> Default for OptionText<'a> { + fn default() -> Self { + OptionText::None + } +} + +impl<'a> From> for OptionText<'a> { + fn from(text: Oco<'a, str>) -> Self { + OptionText::Text(text) + } +} + +impl<'a> From<&'a str> for OptionText<'a> { + fn from(text: &'a str) -> Self { + OptionText::Text(Oco::Borrowed(text)) + } +} + +impl<'a> From for OptionText<'a> { + fn from(text: String) -> Self { + OptionText::Text(Oco::Owned(text)) + } +} + +pub type Text = Oco<'static, str>; + +/// "Owned Clones Once" - a smart pointer that can be either a reference, +/// an owned value, or a reference counted pointer. This is useful for +/// storing immutable values, such as strings, in a way that is cheap to +/// clone and pass around. +/// +/// The `Clone` implementation is amortized `O(1)`. Cloning the [`Oco::Borrowed`] +/// variant simply copies the references (`O(1)`). Cloning the [`Oco::Counted`] +/// variant increments a reference count (`O(1)`). Cloning the [`Oco::Owned`] +/// variant upgrades it to [`Oco::Counted`], which requires an `O(n)` clone of the +/// data, but all subsequent clones will be `O(1)`. +pub enum Oco<'a, T: ?Sized + ToOwned + 'a> { + /// A static reference to a value. + Borrowed(&'a T), + /// A reference counted pointer to a value. + Counted(Arc), + /// An owned value. + Owned(::Owned), +} + +impl<'a, T: ?Sized + ToOwned> Oco<'a, T> { + /// Converts the value into an owned value. + pub fn into_owned(self) -> ::Owned { + match self { + Oco::Borrowed(v) => v.to_owned(), + Oco::Counted(v) => v.as_ref().to_owned(), + Oco::Owned(v) => v, + } + } + + /// Checks if the value is [`Oco::Borrowed`]. + /// # Examples + /// ``` + /// # use std::sync::Arc; + /// # use oco_ref::Oco; + /// assert!(Oco::::Borrowed("Hello").is_borrowed()); + /// assert!(!Oco::::Counted(Arc::from("Hello")).is_borrowed()); + /// assert!(!Oco::::Owned("Hello".to_string()).is_borrowed()); + /// ``` + pub const fn is_borrowed(&self) -> bool { + matches!(self, Oco::Borrowed(_)) + } + + /// Checks if the value is [`Oco::Counted`]. + /// # Examples + /// ``` + /// # use std::sync::Arc; + /// # use oco_ref::Oco; + /// assert!(Oco::::Counted(Arc::from("Hello")).is_counted()); + /// assert!(!Oco::::Borrowed("Hello").is_counted()); + /// assert!(!Oco::::Owned("Hello".to_string()).is_counted()); + /// ``` + pub const fn is_counted(&self) -> bool { + matches!(self, Oco::Counted(_)) + } + + /// Checks if the value is [`Oco::Owned`]. + /// # Examples + /// ``` + /// # use std::sync::Arc; + /// # use oco_ref::Oco; + /// assert!(Oco::::Owned("Hello".to_string()).is_owned()); + /// assert!(!Oco::::Borrowed("Hello").is_owned()); + /// assert!(!Oco::::Counted(Arc::from("Hello")).is_owned()); + /// ``` + pub const fn is_owned(&self) -> bool { + matches!(self, Oco::Owned(_)) + } +} + +impl Deref for Oco<'_, T> { + type Target = T; + + fn deref(&self) -> &T { + match self { + Oco::Borrowed(v) => v, + Oco::Owned(v) => v.borrow(), + Oco::Counted(v) => v, + } + } +} + +impl Borrow for Oco<'_, T> { + #[inline(always)] + fn borrow(&self) -> &T { + self.deref() + } +} + +impl AsRef for Oco<'_, T> { + #[inline(always)] + fn as_ref(&self) -> &T { + self.deref() + } +} + +impl AsRef for Oco<'_, str> { + #[inline(always)] + fn as_ref(&self) -> &Path { + self.as_str().as_ref() + } +} + +impl AsRef for Oco<'_, OsStr> { + #[inline(always)] + fn as_ref(&self) -> &Path { + self.as_os_str().as_ref() + } +} + +// -------------------------------------- +// pub fn as_{slice}(&self) -> &{slice} +// -------------------------------------- + +impl Oco<'_, str> { + /// Returns a `&str` slice of this [`Oco`]. + /// # Examples + /// ``` + /// # use oco_ref::Oco; + /// let oco = Oco::::Borrowed("Hello"); + /// let s: &str = oco.as_str(); + /// assert_eq!(s, "Hello"); + /// ``` + #[inline(always)] + pub fn as_str(&self) -> &str { + self + } +} + +impl Oco<'_, CStr> { + /// Returns a `&CStr` slice of this [`Oco`]. + /// # Examples + /// ``` + /// # use oco_ref::Oco; + /// use std::ffi::CStr; + /// + /// let oco = + /// Oco::::Borrowed(CStr::from_bytes_with_nul(b"Hello\0").unwrap()); + /// let s: &CStr = oco.as_c_str(); + /// assert_eq!(s, CStr::from_bytes_with_nul(b"Hello\0").unwrap()); + /// ``` + #[inline(always)] + pub fn as_c_str(&self) -> &CStr { + self + } +} + +impl Oco<'_, OsStr> { + /// Returns a `&OsStr` slice of this [`Oco`]. + /// # Examples + /// ``` + /// # use oco_ref::Oco; + /// use std::ffi::OsStr; + /// + /// let oco = Oco::::Borrowed(OsStr::new("Hello")); + /// let s: &OsStr = oco.as_os_str(); + /// assert_eq!(s, OsStr::new("Hello")); + /// ``` + #[inline(always)] + pub fn as_os_str(&self) -> &OsStr { + self + } +} + +impl Oco<'_, Path> { + /// Returns a `&Path` slice of this [`Oco`]. + /// # Examples + /// ``` + /// # use oco_ref::Oco; + /// use std::path::Path; + /// + /// let oco = Oco::::Borrowed(Path::new("Hello")); + /// let s: &Path = oco.as_path(); + /// assert_eq!(s, Path::new("Hello")); + /// ``` + #[inline(always)] + pub fn as_path(&self) -> &Path { + self + } +} + +impl Oco<'_, [T]> +where + [T]: ToOwned, +{ + /// Returns a `&[T]` slice of this [`Oco`]. + /// # Examples + /// ``` + /// # use oco_ref::Oco; + /// let oco = Oco::<[u8]>::Borrowed(b"Hello"); + /// let s: &[u8] = oco.as_slice(); + /// assert_eq!(s, b"Hello"); + /// ``` + #[inline(always)] + pub fn as_slice(&self) -> &[T] { + self + } +} + +impl<'a, T> Clone for Oco<'a, T> +where + T: ?Sized + ToOwned + 'a, + for<'b> Arc: From<&'b T>, +{ + /// Returns a new [`Oco`] with the same value as this one. + /// If the value is [`Oco::Owned`], this will convert it into + /// [`Oco::Counted`], so that the next clone will be O(1). + /// # Examples + /// [`String`] : + /// ``` + /// # use oco_ref::Oco; + /// let oco = Oco::::Owned("Hello".to_string()); + /// let oco2 = oco.clone(); + /// assert_eq!(oco, oco2); + /// assert!(oco2.is_counted()); + /// ``` + /// [`Vec`] : + /// ``` + /// # use oco_ref::Oco; + /// let oco = Oco::<[u8]>::Owned(b"Hello".to_vec()); + /// let oco2 = oco.clone(); + /// assert_eq!(oco, oco2); + /// assert!(oco2.is_counted()); + /// ``` + fn clone(&self) -> Self { + match self { + Self::Borrowed(v) => Self::Borrowed(v), + Self::Counted(v) => Self::Counted(Arc::clone(v)), + Self::Owned(v) => Self::Counted(Arc::from(v.borrow())), + } + } +} + +impl<'a, T> Oco<'a, T> +where + T: ?Sized + ToOwned + 'a, + for<'b> Arc: From<&'b T>, +{ + /// Clones the value with inplace conversion into [`Oco::Counted`] if it + /// was previously [`Oco::Owned`]. + /// # Examples + /// ``` + /// # use oco_ref::Oco; + /// let mut oco1 = Oco::::Owned("Hello".to_string()); + /// let oco2 = oco1.clone_inplace(); + /// assert_eq!(oco1, oco2); + /// assert!(oco1.is_counted()); + /// assert!(oco2.is_counted()); + /// ``` + pub fn clone_inplace(&mut self) -> Self { + match &*self { + Self::Borrowed(v) => Self::Borrowed(v), + Self::Counted(v) => Self::Counted(Arc::clone(v)), + Self::Owned(v) => { + let rc = Arc::from(v.borrow()); + *self = Self::Counted(rc.clone()); + Self::Counted(rc) + } + } + } + + /// Converts the value into its cheaply-clonable form in place. + /// In other words, if it is currently [`Oco::Owned`], converts into [`Oco::Counted`] + /// in an `O(n)` operation, so that all future clones are `O(1)`. + /// + /// # Examples + /// ``` + /// # use oco_ref::Oco; + /// let mut oco = Oco::::Owned("Hello".to_string()); + /// oco.upgrade_inplace(); + /// assert!(oco.is_counted()); + /// ``` + pub fn upgrade_inplace(&mut self) { + if let Self::Owned(v) = &*self { + let rc = Arc::from(v.borrow()); + *self = Self::Counted(rc); + } + } +} + +impl Default for Oco<'_, T> +where + T: ToOwned, + T::Owned: Default, +{ + fn default() -> Self { + Oco::Owned(T::Owned::default()) + } +} + +impl<'a, 'b, A: ?Sized, B: ?Sized> PartialEq> for Oco<'a, A> +where + A: PartialEq, + A: ToOwned, + B: ToOwned, +{ + fn eq(&self, other: &Oco<'b, B>) -> bool { + **self == **other + } +} + +impl Eq for Oco<'_, T> {} + +impl<'a, 'b, A: ?Sized, B: ?Sized> PartialOrd> for Oco<'a, A> +where + A: PartialOrd, + A: ToOwned, + B: ToOwned, +{ + fn partial_cmp(&self, other: &Oco<'b, B>) -> Option { + (**self).partial_cmp(&**other) + } +} + +impl Ord for Oco<'_, T> +where + T: ToOwned, +{ + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (**self).cmp(&**other) + } +} + +impl Hash for Oco<'_, T> +where + T: ToOwned, +{ + fn hash(&self, state: &mut H) { + (**self).hash(state) + } +} + +impl fmt::Debug for Oco<'_, T> +where + T: ToOwned, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + (**self).fmt(f) + } +} + +impl fmt::Display for Oco<'_, T> +where + T: ToOwned, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + (**self).fmt(f) + } +} + +impl<'a, T: ?Sized> From<&'a T> for Oco<'a, T> +where + T: ToOwned, +{ + fn from(v: &'a T) -> Self { + Oco::Borrowed(v) + } +} + +impl<'a, T: ?Sized> From> for Oco<'a, T> +where + T: ToOwned, +{ + fn from(v: Cow<'a, T>) -> Self { + match v { + Cow::Borrowed(v) => Oco::Borrowed(v), + Cow::Owned(v) => Oco::Owned(v), + } + } +} + +impl<'a, T: ?Sized> From> for Cow<'a, T> +where + T: ToOwned, +{ + fn from(value: Oco<'a, T>) -> Self { + match value { + Oco::Borrowed(v) => Cow::Borrowed(v), + Oco::Owned(v) => Cow::Owned(v), + Oco::Counted(v) => Cow::Owned(v.as_ref().to_owned()), + } + } +} + +impl From> for Oco<'_, T> +where + T: ToOwned, +{ + fn from(v: Arc) -> Self { + Oco::Counted(v) + } +} + +impl From> for Oco<'_, T> +where + T: ToOwned, +{ + fn from(v: Box) -> Self { + Oco::Counted(v.into()) + } +} + +impl From for Oco<'_, str> { + fn from(v: String) -> Self { + Oco::Owned(v) + } +} + +impl From> for String { + fn from(v: Oco<'_, str>) -> Self { + match v { + Oco::Borrowed(v) => v.to_owned(), + Oco::Counted(v) => v.as_ref().to_owned(), + Oco::Owned(v) => v, + } + } +} + +impl From> for Oco<'_, [T]> +where + [T]: ToOwned>, +{ + fn from(v: Vec) -> Self { + Oco::Owned(v) + } +} + +impl<'a, T, const N: usize> From<&'a [T; N]> for Oco<'a, [T]> +where + [T]: ToOwned, +{ + fn from(v: &'a [T; N]) -> Self { + Oco::Borrowed(v) + } +} + +impl<'a> From> for Oco<'a, [u8]> { + fn from(v: Oco<'a, str>) -> Self { + match v { + Oco::Borrowed(v) => Oco::Borrowed(v.as_bytes()), + Oco::Owned(v) => Oco::Owned(v.into_bytes()), + Oco::Counted(v) => Oco::Counted(v.into()), + } + } +} + +/// Error returned from `Oco::try_from` for unsuccessful +/// conversion from `Oco<'_, [u8]>` to `Oco<'_, str>`. +#[derive(Debug, Clone, thiserror::Error)] +#[error("invalid utf-8 sequence: {_0}")] +pub enum FromUtf8Error { + /// Error for conversion of [`Oco::Borrowed`] and [`Oco::Counted`] variants + /// (`&[u8]` to `&str`). + #[error("{_0}")] + StrFromBytes( + #[source] + #[from] + std::str::Utf8Error, + ), + /// Error for conversion of [`Oco::Owned`] variant (`Vec` to `String`). + #[error("{_0}")] + StringFromBytes( + #[source] + #[from] + std::string::FromUtf8Error, + ), +} + +macro_rules! impl_slice_eq { + ([$($g:tt)*] $((where $($w:tt)+))?, $lhs:ty, $rhs: ty) => { + impl<$($g)*> PartialEq<$rhs> for $lhs + $(where + $($w)*)? + { + #[inline] + fn eq(&self, other: &$rhs) -> bool { + PartialEq::eq(&self[..], &other[..]) + } + } + + impl<$($g)*> PartialEq<$lhs> for $rhs + $(where + $($w)*)? + { + #[inline] + fn eq(&self, other: &$lhs) -> bool { + PartialEq::eq(&self[..], &other[..]) + } + } + }; +} + +impl_slice_eq!([], Oco<'_, str>, str); +impl_slice_eq!(['a, 'b], Oco<'a, str>, &'b str); +impl_slice_eq!([], Oco<'_, str>, String); +impl_slice_eq!(['a, 'b], Oco<'a, str>, Cow<'b, str>); + +impl_slice_eq!([T: PartialEq] (where [T]: ToOwned), Oco<'_, [T]>, [T]); +impl_slice_eq!(['a, 'b, T: PartialEq] (where [T]: ToOwned), Oco<'a, [T]>, &'b [T]); +impl_slice_eq!([T: PartialEq] (where [T]: ToOwned), Oco<'_, [T]>, Vec); +impl_slice_eq!(['a, 'b, T: PartialEq] (where [T]: ToOwned), Oco<'a, [T]>, Cow<'b, [T]>); + +impl<'a, 'b> Add<&'b str> for Oco<'a, str> { + type Output = Oco<'static, str>; + + fn add(self, rhs: &'b str) -> Self::Output { + Oco::Owned(String::from(self) + rhs) + } +} + +impl<'a, 'b> Add> for Oco<'a, str> { + type Output = Oco<'static, str>; + + fn add(self, rhs: Cow<'b, str>) -> Self::Output { + Oco::Owned(String::from(self) + rhs.as_ref()) + } +} + +impl<'a, 'b> Add> for Oco<'a, str> { + type Output = Oco<'static, str>; + + fn add(self, rhs: Oco<'b, str>) -> Self::Output { + Oco::Owned(String::from(self) + rhs.as_ref()) + } +} + +impl<'a> FromIterator> for String { + fn from_iter>>(iter: T) -> Self { + iter.into_iter().fold(String::new(), |mut acc, item| { + acc.push_str(item.as_ref()); + acc + }) + } +} + +impl<'a, T> Deserialize<'a> for Oco<'static, T> +where + T: ?Sized + ToOwned + 'a, + T::Owned: DeserializeOwned, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'a>, + { + ::deserialize(deserializer).map(Oco::Owned) + } +} + +impl<'a, T> Serialize for Oco<'a, T> +where + T: ?Sized + ToOwned + 'a, + for<'b> &'b T: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.as_ref().serialize(serializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_fmt_should_display_quotes_for_strings() { + let s: Oco = Oco::Borrowed("hello"); + assert_eq!(format!("{:?}", s), "\"hello\""); + let s: Oco = Oco::Counted(Arc::from("hello")); + assert_eq!(format!("{:?}", s), "\"hello\""); + } + + #[test] + fn partial_eq_should_compare_str_to_str() { + let s: Oco = Oco::Borrowed("hello"); + assert_eq!(s, "hello"); + assert_eq!("hello", s); + assert_eq!(s, String::from("hello")); + assert_eq!(String::from("hello"), s); + assert_eq!(s, Cow::from("hello")); + assert_eq!(Cow::from("hello"), s); + } + + #[test] + fn partial_eq_should_compare_slice_to_slice() { + let s: Oco<[i32]> = Oco::Borrowed([1, 2, 3].as_slice()); + assert_eq!(s, [1, 2, 3].as_slice()); + assert_eq!([1, 2, 3].as_slice(), s); + assert_eq!(s, vec![1, 2, 3]); + assert_eq!(vec![1, 2, 3], s); + assert_eq!(s, Cow::<'_, [i32]>::Borrowed(&[1, 2, 3])); + assert_eq!(Cow::<'_, [i32]>::Borrowed(&[1, 2, 3]), s); + } + + #[test] + fn add_should_concatenate_strings() { + let s: Oco = Oco::Borrowed("hello"); + assert_eq!(s.clone() + " world", "hello world"); + assert_eq!(s.clone() + Cow::from(" world"), "hello world"); + assert_eq!(s + Oco::from(" world"), "hello world"); + } + + #[test] + fn as_str_should_return_a_str() { + let s: Oco = Oco::Borrowed("hello"); + assert_eq!(s.as_str(), "hello"); + let s: Oco = Oco::Counted(Arc::from("hello")); + assert_eq!(s.as_str(), "hello"); + } + + #[test] + fn as_slice_should_return_a_slice() { + let s: Oco<[i32]> = Oco::Borrowed([1, 2, 3].as_slice()); + assert_eq!(s.as_slice(), [1, 2, 3].as_slice()); + let s: Oco<[i32]> = Oco::Counted(Arc::from([1, 2, 3])); + assert_eq!(s.as_slice(), [1, 2, 3].as_slice()); + } + + #[test] + fn default_for_str_should_return_an_empty_string() { + let s: Oco = Default::default(); + assert!(s.is_empty()); + } + + #[test] + fn default_for_slice_should_return_an_empty_slice() { + let s: Oco<[i32]> = Default::default(); + assert!(s.is_empty()); + } + + #[test] + fn default_for_any_option_should_return_none() { + let s: Oco> = Default::default(); + assert!(s.is_none()); + } + + #[test] + fn cloned_owned_string_should_make_counted_str() { + let s: Oco = Oco::Owned(String::from("hello")); + assert!(s.clone().is_counted()); + } + + #[test] + fn cloned_borrowed_str_should_make_borrowed_str() { + let s: Oco = Oco::Borrowed("hello"); + assert!(s.clone().is_borrowed()); + } + + #[test] + fn cloned_counted_str_should_make_counted_str() { + let s: Oco = Oco::Counted(Arc::from("hello")); + assert!(s.clone().is_counted()); + } + + #[test] + fn cloned_inplace_owned_string_should_make_counted_str_and_become_counted() + { + let mut s: Oco = Oco::Owned(String::from("hello")); + assert!(s.clone_inplace().is_counted()); + assert!(s.is_counted()); + } + + #[test] + fn cloned_inplace_borrowed_str_should_make_borrowed_str_and_remain_borrowed( + ) { + let mut s: Oco = Oco::Borrowed("hello"); + assert!(s.clone_inplace().is_borrowed()); + assert!(s.is_borrowed()); + } + + #[test] + fn cloned_inplace_counted_str_should_make_counted_str_and_remain_counted() { + let mut s: Oco = Oco::Counted(Arc::from("hello")); + assert!(s.clone_inplace().is_counted()); + assert!(s.is_counted()); + } + + #[test] + fn serialization_works() { + let s = serde_json::to_string(&Oco::Borrowed("foo")) + .expect("should serialize string"); + assert_eq!(s, "\"foo\""); + } + + #[test] + fn deserialization_works() { + let s: Oco = serde_json::from_str("\"bar\"") + .expect("should deserialize from string"); + assert_eq!(s, Oco::from(String::from("bar"))); + } +} diff --git a/watch.sh b/watch.sh new file mode 100755 index 0000000..f2e10db --- /dev/null +++ b/watch.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +cargo watch -s "bun tailwind:build; cargo run"