├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── example └── start-axum │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ ├── public │ ├── cute_ferris.png │ └── favicon.ico │ ├── rust-toolchain.toml │ ├── src │ ├── app.rs │ ├── error_template.rs │ ├── fileserv.rs │ ├── lib.rs │ └── main.rs │ └── style │ └── main.scss └── src ├── image.rs ├── lib.rs ├── optimizer.rs ├── provider.rs └── routes.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | 4 | **/.DS_Store -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "leptos_image" 3 | version = "0.2.0" 4 | edition = "2021" 5 | authors = ["Nico Burniske"] 6 | description = "Static Image Optimizer for Leptos" 7 | exclude = ["example/"] 8 | keywords = ["leptos"] 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/nicoburniske/leptos-image" 12 | 13 | [dependencies] 14 | leptos = { version = "0.6", default-features = false } 15 | leptos_router = { version = "0.6", default-features = false } 16 | leptos_meta = { version = "0.6", default-features = false } 17 | 18 | wasm-bindgen = "0.2" 19 | web-sys = { version = "0.3", optional = true, features = ["HtmlImageElement"]} 20 | 21 | tokio = { version = "1", features = ["rt-multi-thread", "rt", "fs"], optional = true } 22 | axum = { version = "0.7", optional = true, features = ["macros"] } 23 | tower = { version = "0.4", optional = true } 24 | tower-http = { version = "0.5", features = ["fs"], optional = true } 25 | 26 | image = { version = "0.24", optional = true} 27 | webp = { version= "0.2", optional = true} 28 | serde = { version = "1.0", features = ["derive"] } 29 | serde_qs = "0.12" 30 | thiserror = { version = "1", optional = true } 31 | base64 = "0.21" 32 | tracing = { version = "0.1", optional = true } 33 | dashmap = { version = "5", optional = true } 34 | 35 | [features] 36 | ssr = [ 37 | "leptos_router/ssr", "leptos_meta/ssr" , "leptos/ssr", 38 | "dep:webp", "dep:image", 39 | "dep:tokio", "dep:axum", "dep:tower", "dep:tower-http", 40 | "dep:tracing", "dep:dashmap", "dep:thiserror" 41 | ] 42 | hydrate = [ "dep:web-sys","leptos/hydrate", "leptos_router/hydrate" ] 43 | 44 | [dev-dependencies] 45 | leptos_axum = "0.6.5" 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nico Burniske 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leptos Image 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/leptos_image.svg)](https://crates.io/crates/leptos_image) 4 | [![docs.rs](https://docs.rs/leptos_image/badge.svg)](https://docs.rs/leptos_image) 5 | 6 | > Crafted with inspiration from Next.js 7 | 8 | Images make a substantial impact on the size and performance of a website, so why not get them right? 9 | 10 | Enter Leptos ``, a component that enhances the standard HTML `` element with automatic image optimization features. 11 | 12 | ## Features 13 | 14 | - **Size Optimization**: Automatically resizes images and converts them to the modern `.webp` format for an optimal balance of size and quality. 15 | - **Low-Quality Image Placeholders (LQIP)**: Embeds SVG placeholders extracted from original images into server-side rendered HTML, improving perceived performance during image loading. 16 | - **Faster Page Load**: Prioritizes critical images, impacting Largest Contentful Paint (LCP), with the `priority` prop by adding a preload `` to the document head, accelerating load times. 17 | 18 | ## Version compatibility for Leptos and Leptos Image 19 | 20 | The table below shows the compatible versions of `leptos_image` for each `leptos` version. Ensure you are using compatible versions to avoid potential issues. 21 | 22 | | `leptos` version | `leptos_image` version | 23 | |------------------|------------------------| 24 | | 0.6.* | 0.2.* | 25 | 26 | 27 | ## Installation 28 | 29 | To add `leptos_image` to your project, use cargo: 30 | 31 | ```bash 32 | cargo add leptos_image --optional 33 | ``` 34 | 35 | Enable the SSR feature in your `Cargo.toml`: 36 | 37 | ```toml 38 | [features] 39 | ssr = [ 40 | "leptos_image/ssr", 41 | # other dependencies... 42 | ] 43 | 44 | hydrate = [ 45 | "leptos_image/hydrate", 46 | # other dependencies... 47 | ] 48 | ``` 49 | 50 | ## Quick Start 51 | 52 | > This requires SSR + Leptos Axum integration 53 | 54 | 1. **Provide Image Context**: Initialize your Leptos application with `leptos_image::provide_image_context` to grant it read access to the image cache. 55 | 56 | ```rust 57 | use leptos::*; 58 | 59 | #[component] 60 | fn App() -> impl IntoView { 61 | leptos_image::provide_image_context(); 62 | // Your app content here 63 | } 64 | ``` 65 | 66 | 2. **Axum State Configuration**: Incorporate `ImageOptimizer` into your app's Axum state. 67 | 68 | ```rust 69 | // Composite App State with the optimizer and leptos options. 70 | #[derive(Clone, axum::extract::FromRef)] 71 | struct AppState { 72 | leptos_options: leptos::LeptosOptions, 73 | optimizer: leptos_image::ImageOptimizer, 74 | } 75 | 76 | ``` 77 | 78 | 3. **Integrate with Router**: Ensure your `ImageOptimizer` is available during SSR of your Leptos App. 79 | - Add Image Cache Route: Use `image_cache_route` to serve cached images. 80 | - Add `ImageOptimizer` to your App state. 81 | - Add `ImageOptimizer` to Leptos Context: Provide the optimizer to Leptos context using `leptos_routes_with_context`. 82 | 83 | ```rust 84 | use leptos::*; 85 | use leptos_axum::*; 86 | use leptos_image::*; 87 | 88 | async fn main() { 89 | // Get Leptos options from configuration. 90 | let conf = get_configuration(None).await.unwrap(); 91 | let leptos_options = conf.leptos_options; 92 | let root = leptos_options.site_root.clone(); 93 | 94 | // Create App State with ImageOptimizer. 95 | let state = AppState { 96 | leptos_options, 97 | optimizer: ImageOptimizer::new("/__cache/image", root, 1), 98 | }; 99 | 100 | // Create your router 101 | let app = Router::new() 102 | .route("/api/*fn_name", post(handle_server_fns)) 103 | // Add a handler for serving the cached images. 104 | .image_cache_route(&state) 105 | // Provide the optimizer to leptos context. 106 | .leptos_routes_with_context(&state, routes, state.optimizer.provide_context(), App) 107 | .fallback(fallback_handler) 108 | // Provide the state to the app. 109 | .with_state(state); 110 | } 111 | ``` 112 | 113 | 114 | A full working example is available in the [examples](./example/start-axum) directory. 115 | 116 | Now you can use the Image Component anywhere in your app! 117 | 118 | ```rust 119 | #[component] 120 | pub fn MyImage() -> impl IntoView { 121 | view! { 122 | 129 | } 130 | } 131 | ``` 132 | 133 | This setup ensures your Leptos application is fully equipped to deliver optimized images, enhancing the performance and user experience of your web projects. 134 | -------------------------------------------------------------------------------- /example/start-axum/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | pkg 5 | Cargo.lock 6 | 7 | # These are backup files generated by rustfmt 8 | **/*.rs.bk 9 | 10 | # node e2e test tools and outputs 11 | node_modules/ 12 | test-results/ 13 | end2end/playwright-report/ 14 | playwright/.cache/ 15 | -------------------------------------------------------------------------------- /example/start-axum/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "start-axum" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | axum = { version = "0.7", optional = true , features = ["macros"]} 11 | console_error_panic_hook = "0.1" 12 | leptos = { version = "0.6", features = ["nightly"] } 13 | leptos_axum = { version = "0.6", optional = true } 14 | leptos_meta = { version = "0.6", features = ["nightly"] } 15 | leptos_router = { version = "0.6", features = ["nightly"] } 16 | tokio = { version = "1", features = ["rt-multi-thread"], optional = true } 17 | tower = { version = "0.4", optional = true } 18 | tower-http = { version = "0.5", features = ["fs"], optional = true } 19 | wasm-bindgen = "=0.2.89" 20 | thiserror = "1" 21 | tracing = { version = "0.1", optional = true } 22 | http = "1" 23 | 24 | cfg-if = "1" 25 | leptos_image = { path = "../../" } 26 | simple_logger = "4" 27 | log = "0.4" 28 | 29 | [features] 30 | hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate", "leptos_image/hydrate"] 31 | ssr = [ 32 | "dep:axum", 33 | "dep:tokio", 34 | "dep:tower", 35 | "dep:tower-http", 36 | "dep:leptos_axum", 37 | "leptos/ssr", 38 | "leptos_meta/ssr", 39 | "leptos_router/ssr", 40 | "dep:tracing", 41 | "leptos_image/ssr" 42 | ] 43 | 44 | [package.metadata.cargo-all-features] 45 | denylist = ["axum", "tokio", "tower", "tower-http", "leptos_axum"] 46 | skip_feature_sets = [["ssr", "hydrate"]] 47 | 48 | [package.metadata.leptos] 49 | # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name 50 | output-name = "start-axum" 51 | 52 | # The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. 53 | site-root = "target/site" 54 | 55 | # The site-root relative folder where all compiled output (JS, WASM and CSS) is written 56 | # Defaults to pkg 57 | site-pkg-dir = "pkg" 58 | 59 | # [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //app.css 60 | style-file = "style/main.scss" 61 | # Assets source dir. All files found here will be copied and synchronized to site-root. 62 | # The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. 63 | # 64 | # Optional. Env: LEPTOS_ASSETS_DIR. 65 | assets-dir = "public" 66 | 67 | # The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. 68 | site-addr = "127.0.0.1:3000" 69 | 70 | # The port to use for automatic reload monitoring 71 | reload-port = 3001 72 | 73 | # [Optional] Command to use when running end2end tests. It will run in the end2end dir. 74 | # [Windows] for non-WSL use "npx.cmd playwright test" 75 | # This binary name can be checked in Powershell with Get-Command npx 76 | end2end-cmd = "npx playwright test" 77 | end2end-dir = "end2end" 78 | 79 | # The browserlist query used for optimizing the CSS. 80 | browserquery = "defaults" 81 | 82 | # Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head 83 | watch = false 84 | 85 | # The environment Leptos will run in, usually either "DEV" or "PROD" 86 | env = "DEV" 87 | 88 | # The features to use when compiling the bin target 89 | # 90 | # Optional. Can be over-ridden with the command line parameter --bin-features 91 | bin-features = ["ssr"] 92 | 93 | # If the --no-default-features flag should be used when compiling the bin target 94 | # 95 | # Optional. Defaults to false. 96 | bin-default-features = false 97 | 98 | # The features to use when compiling the lib target 99 | # 100 | # Optional. Can be over-ridden with the command line parameter --lib-features 101 | lib-features = ["hydrate"] 102 | 103 | # If the --no-default-features flag should be used when compiling the lib target 104 | # 105 | # Optional. Defaults to false. 106 | lib-default-features = false 107 | -------------------------------------------------------------------------------- /example/start-axum/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Leptos Logo 4 | 5 | 6 | # Leptos Axum Starter Template 7 | 8 | This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool using [Axum](https://github.com/tokio-rs/axum). 9 | 10 | ## Creating your template repo 11 | 12 | If you don't have `cargo-leptos` installed you can install it with 13 | 14 | ```bash 15 | cargo install cargo-leptos 16 | ``` 17 | 18 | Then run 19 | ```bash 20 | cargo leptos new --git leptos-rs/start-axum 21 | ``` 22 | 23 | to generate a new project template. 24 | 25 | ```bash 26 | cd {projectname} 27 | ``` 28 | 29 | to go to your newly created project. 30 | Feel free to explore the project structure, but the best place to start with your application code is in `src/app.rs`. 31 | Addtionally, Cargo.toml may need updating as new versions of the dependencies are released, especially if things are not working after a `cargo update`. 32 | 33 | ## Running your project 34 | 35 | ```bash 36 | cargo leptos watch 37 | ``` 38 | 39 | ## Installing Additional Tools 40 | 41 | By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools. 42 | 43 | 1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly 44 | 2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly 45 | 3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future) 46 | 4. `npm install -g sass` - install `dart-sass` (should be optional in future 47 | 48 | ## Compiling for Release 49 | ```bash 50 | cargo leptos build --release 51 | ``` 52 | 53 | Will generate your server binary in target/server/release and your site package in target/site 54 | 55 | ## Testing Your Project 56 | ```bash 57 | cargo leptos end-to-end 58 | ``` 59 | 60 | ```bash 61 | cargo leptos end-to-end --release 62 | ``` 63 | 64 | Cargo-leptos uses Playwright as the end-to-end test tool. 65 | Tests are located in end2end/tests directory. 66 | 67 | ## Executing a Server on a Remote Machine Without the Toolchain 68 | After running a `cargo leptos build --release` the minimum files needed are: 69 | 70 | 1. The server binary located in `target/server/release` 71 | 2. The `site` directory and all files within located in `target/site` 72 | 73 | Copy these files to your remote server. The directory structure should be: 74 | ```text 75 | start-axum 76 | site/ 77 | ``` 78 | Set the following environment variables (updating for your project as needed): 79 | ```text 80 | LEPTOS_OUTPUT_NAME="start-axum" 81 | LEPTOS_SITE_ROOT="site" 82 | LEPTOS_SITE_PKG_DIR="pkg" 83 | LEPTOS_SITE_ADDR="127.0.0.1:3000" 84 | LEPTOS_RELOAD_PORT="3001" 85 | ``` 86 | Finally, run the server binary. 87 | -------------------------------------------------------------------------------- /example/start-axum/public/cute_ferris.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaucho-labs/leptos-image/3b86814beb0cec7de7ba4943a2141cc88c97d705/example/start-axum/public/cute_ferris.png -------------------------------------------------------------------------------- /example/start-axum/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaucho-labs/leptos-image/3b86814beb0cec7de7ba4943a2141cc88c97d705/example/start-axum/public/favicon.ico -------------------------------------------------------------------------------- /example/start-axum/rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | 2 | [toolchain] 3 | channel = "nightly" 4 | -------------------------------------------------------------------------------- /example/start-axum/src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::error_template::{AppError, ErrorTemplate}; 2 | use leptos::*; 3 | use leptos_image::{provide_image_context, Image}; 4 | use leptos_meta::*; 5 | use leptos_router::*; 6 | 7 | #[component] 8 | pub fn App() -> impl IntoView { 9 | // Provides context that manages stylesheets, titles, meta tags, etc. 10 | provide_meta_context(); 11 | provide_image_context(); 12 | 13 | view! { 14 | 15 | 16 | <Router fallback=|| { 17 | let mut outside_errors = Errors::default(); 18 | outside_errors.insert_with_default_key(AppError::NotFound); 19 | view! { <ErrorTemplate outside_errors/> }.into_view() 20 | }> 21 | <main> 22 | <Routes> 23 | <Route 24 | path="" 25 | view=|| { 26 | view! { 27 | <div 28 | style:display="flex" 29 | style:width="40rem" 30 | style:justify-content="space-between" 31 | style:margin-left="auto" 32 | style:margin-right="auto" 33 | > 34 | <div> 35 | <a href="/">"Home"</a> 36 | </div> 37 | <div> 38 | <a href="/lg">"Large"</a> 39 | </div> 40 | <div> 41 | <a href="/md">"Medium"</a> 42 | </div> 43 | <div> 44 | <a href="/sm">"Small"</a> 45 | </div> 46 | <div> 47 | <a href="/no-blur">"No Blur"</a> 48 | </div> 49 | </div> 50 | <Outlet/> 51 | } 52 | } 53 | > 54 | 55 | <Route 56 | path="/" 57 | view=|| { 58 | view! { <h1>"Welcome to Leptos Image"</h1> } 59 | } 60 | /> 61 | 62 | <Route 63 | path="/lg" 64 | view=|| { 65 | view! { <ImageComparison width=1000 height=1000 blur=true/> } 66 | } 67 | /> 68 | 69 | <Route 70 | path="/md" 71 | view=|| { 72 | view! { <ImageComparison width=500 height=500 blur=true/> } 73 | } 74 | /> 75 | 76 | <Route 77 | path="/sm" 78 | view=|| { 79 | view! { <ImageComparison width=100 height=100 blur=true/> } 80 | } 81 | /> 82 | 83 | <Route 84 | path="/no-blur" 85 | view=|| { 86 | view! { <ImageComparison width=1000 height=1000 blur=false/> } 87 | } 88 | /> 89 | 90 | </Route> 91 | </Routes> 92 | </main> 93 | </Router> 94 | } 95 | } 96 | 97 | #[component] 98 | fn ImageComparison(width: u32, height: u32, blur: bool) -> impl IntoView { 99 | view! { 100 | <div 101 | style:margin-left="auto" 102 | style:margin-right="auto" 103 | style:display="flex" 104 | style:justify-content="space-around" 105 | style:align-items="center" 106 | style:gap="1rem" 107 | > 108 | <div> 109 | <div> 110 | <h1>{format!("Optimized ({width} x {height}) with blur preview")}</h1> 111 | </div> 112 | <Image src="/cute_ferris.png" width height quality=85 blur class="test-image"/> 113 | </div> 114 | <div> 115 | <div> 116 | <h1>"Normal Image"</h1> 117 | </div> 118 | <img src="/cute_ferris.png" class="test-image"/> 119 | </div> 120 | </div> 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /example/start-axum/src/error_template.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | use http::status::StatusCode; 3 | use leptos::*; 4 | use thiserror::Error; 5 | 6 | #[cfg(feature = "ssr")] 7 | use leptos_axum::ResponseOptions; 8 | 9 | #[derive(Clone, Debug, Error)] 10 | pub enum AppError { 11 | #[error("Not Found")] 12 | NotFound, 13 | } 14 | 15 | impl AppError { 16 | pub fn status_code(&self) -> StatusCode { 17 | match self { 18 | AppError::NotFound => StatusCode::NOT_FOUND, 19 | } 20 | } 21 | } 22 | 23 | // A basic function to display errors served by the error boundaries. 24 | // Feel free to do more complicated things here than just displaying the error. 25 | #[component] 26 | pub fn ErrorTemplate( 27 | #[prop(optional)] outside_errors: Option<Errors>, 28 | #[prop(optional)] errors: Option<RwSignal<Errors>>, 29 | ) -> impl IntoView { 30 | let errors = match outside_errors { 31 | Some(e) => create_rw_signal(e), 32 | None => match errors { 33 | Some(e) => e, 34 | None => panic!("No Errors found and we expected errors!"), 35 | }, 36 | }; 37 | // Get Errors from Signal 38 | let errors = errors.get(); 39 | 40 | // Downcast lets us take a type that implements `std::error::Error` 41 | let errors: Vec<AppError> = errors 42 | .into_iter() 43 | .filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned()) 44 | .collect(); 45 | println!("Errors: {errors:#?}"); 46 | 47 | // Only the response code for the first error is actually sent from the server 48 | // this may be customized by the specific application 49 | cfg_if! { if #[cfg(feature="ssr")] { 50 | let response = use_context::<ResponseOptions>(); 51 | if let Some(response) = response { 52 | response.set_status(errors[0].status_code()); 53 | } 54 | }} 55 | 56 | view! { 57 | <h1>{if errors.len() > 1 { "Errors" } else { "Error" }}</h1> 58 | <For 59 | // a function that returns the items we're iterating over; a signal is fine 60 | each=move || { errors.clone().into_iter().enumerate() } 61 | // a unique key for each item as a reference 62 | key=|(index, _error)| *index 63 | // renders each item to a view 64 | children=move |error| { 65 | let error_string = error.1.to_string(); 66 | let error_code = error.1.status_code(); 67 | view! { 68 | <h2>{error_code.to_string()}</h2> 69 | <p>"Error: " {error_string}</p> 70 | } 71 | } 72 | /> 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /example/start-axum/src/fileserv.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | 3 | cfg_if! { if #[cfg(feature = "ssr")] { 4 | use axum::{ 5 | body::{Body}, 6 | extract::State, 7 | response::IntoResponse, 8 | http::{Request, Response, StatusCode, Uri}, 9 | }; 10 | use axum::response::Response as AxumResponse; 11 | use tower::ServiceExt; 12 | use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody}; 13 | use leptos::*; 14 | use crate::app::App; 15 | 16 | pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse { 17 | let root = options.site_root.clone(); 18 | let res = get_static_file(uri.clone(), &root).await.unwrap(); 19 | 20 | if res.status() == StatusCode::OK { 21 | res.into_response() 22 | } else { 23 | let handler = leptos_axum::render_app_to_stream(options.to_owned(), move || view!{ <App/>}); 24 | handler(req).await.into_response() 25 | } 26 | } 27 | 28 | async fn get_static_file(uri: Uri, root: &str) -> Result<Response<ServeFileSystemResponseBody>, (StatusCode, String)> { 29 | let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap(); 30 | // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` 31 | // This path is relative to the cargo root 32 | ServeDir::new(root).oneshot(req).await.map_err(|err| { 33 | ( 34 | StatusCode::INTERNAL_SERVER_ERROR, 35 | format!("Something went wrong: {err}"), 36 | ) 37 | }) 38 | } 39 | }} 40 | -------------------------------------------------------------------------------- /example/start-axum/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod error_template; 3 | #[cfg(feature = "ssr")] 4 | pub mod fileserv; 5 | 6 | #[cfg(feature = "hydrate")] 7 | #[wasm_bindgen::prelude::wasm_bindgen] 8 | pub fn hydrate() { 9 | use crate::app::*; 10 | console_error_panic_hook::set_once(); 11 | leptos::mount_to_body(App); 12 | } 13 | -------------------------------------------------------------------------------- /example/start-axum/src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "ssr")] 2 | #[tokio::main] 3 | async fn main() { 4 | use axum::{routing::post, Router}; 5 | use leptos::*; 6 | use leptos_axum::{generate_route_list, handle_server_fns, LeptosRoutes}; 7 | use leptos_image::*; 8 | use start_axum::app::*; 9 | use start_axum::fileserv::file_and_error_handler; 10 | use tokio::net::TcpListener; 11 | 12 | // Composite App State with the optimizer and leptos options. 13 | #[derive(Clone, axum::extract::FromRef)] 14 | struct AppState { 15 | leptos_options: leptos::LeptosOptions, 16 | optimizer: leptos_image::ImageOptimizer, 17 | } 18 | simple_logger::init_with_level(log::Level::Info).expect("couldn't initialize logging"); 19 | 20 | // Setting get_configuration(None) means we'll be using cargo-leptos's env values 21 | // For deployment these variables are: 22 | // <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain> 23 | // Alternately a file can be specified such as Some("Cargo.toml") 24 | // The file would need to be included with the executable when moved to deployment 25 | let conf = get_configuration(None).await.unwrap(); 26 | let leptos_options = conf.leptos_options; 27 | let addr = leptos_options.site_addr; 28 | let routes = generate_route_list(App); 29 | 30 | let conf = get_configuration(None).await.unwrap(); 31 | let leptos_options = conf.leptos_options; 32 | let root = leptos_options.site_root.clone(); 33 | 34 | let state = AppState { 35 | leptos_options, 36 | optimizer: ImageOptimizer::new("/cache/image", root, 1), 37 | }; 38 | 39 | // Build Router. 40 | let app = Router::new() 41 | .route("/api/*fn_name", post(handle_server_fns)) 42 | // Add a handler for serving the cached images. 43 | .image_cache_route(&state) 44 | // Provide the optimizer to leptos context. 45 | .leptos_routes_with_context(&state, routes, state.optimizer.provide_context(), App) 46 | .fallback(file_and_error_handler) 47 | .with_state(state); 48 | 49 | // run our app with hyper 50 | // `axum::Server` is a re-export of `hyper::Server` 51 | let listener = TcpListener::bind(&addr).await.unwrap(); 52 | logging::log!("listening on http://{}", &addr); 53 | axum::serve(listener, app.into_make_service()) 54 | .await 55 | .unwrap(); 56 | } 57 | 58 | #[cfg(not(feature = "ssr"))] 59 | pub fn main() { 60 | // no client-side main function 61 | // unless we want this to work with e.g., Trunk for a purely client-side app 62 | // see lib.rs for hydration function instead 63 | } 64 | -------------------------------------------------------------------------------- /example/start-axum/style/main.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | text-align: center; 4 | width: 100vw; 5 | } 6 | .test-image { 7 | width: 500px; 8 | height: 500px; 9 | object-fit: cover; 10 | } 11 | -------------------------------------------------------------------------------- /src/image.rs: -------------------------------------------------------------------------------- 1 | use crate::optimizer::*; 2 | 3 | use leptos::*; 4 | use leptos_meta::Link; 5 | 6 | /** 7 | */ 8 | 9 | /// Image component for rendering optimized static images. 10 | /// Images MUST be static. Will not work with dynamic images. 11 | #[component] 12 | pub fn Image( 13 | /// Image source. Should be path relative to root. 14 | #[prop(into)] 15 | src: String, 16 | /// Resize image height, but will still maintain the same aspect ratio. 17 | height: u32, 18 | /// Resize image width, but will still maintain the same aspect ratio. 19 | width: u32, 20 | /// Image quality. 0-100. 21 | #[prop(default = 75_u8)] 22 | quality: u8, 23 | /// Will add blur image to head if true. 24 | #[prop(default = false)] 25 | blur: bool, 26 | /// Will add preload link to head if true. 27 | #[prop(default = false)] 28 | priority: bool, 29 | /// Lazy load image. 30 | #[prop(default = true)] 31 | lazy: bool, 32 | /// Image alt text. 33 | #[prop(into, optional)] 34 | alt: String, 35 | /// Style class for image. 36 | #[prop(into, optional)] 37 | class: Option<AttributeValue>, 38 | ) -> impl IntoView { 39 | if src.starts_with("http") { 40 | logging::debug_warn!("Image component only supports static images."); 41 | let loading = if lazy { "lazy" } else { "eager" }; 42 | return view! { <img src=src alt=alt class=class loading=loading/> }.into_view(); 43 | } 44 | 45 | let blur_image = { 46 | CachedImage { 47 | src: src.clone(), 48 | option: CachedImageOption::Blur(Blur { 49 | width: 20, 50 | height: 20, 51 | svg_width: 100, 52 | svg_height: 100, 53 | sigma: 15, 54 | }), 55 | } 56 | }; 57 | 58 | let opt_image = { 59 | CachedImage { 60 | src: src.clone(), 61 | option: CachedImageOption::Resize(Resize { 62 | quality, 63 | width, 64 | height, 65 | }), 66 | } 67 | }; 68 | 69 | // Retrieve value from Cache if it exists. Doing this per-image to allow image introspection. 70 | let resource = crate::use_image_cache_resource(); 71 | 72 | let blur_image = store_value(blur_image); 73 | let opt_image = store_value(opt_image); 74 | let alt = store_value(alt); 75 | let class = store_value(class.map(|c| c.into_attribute_boxed())); 76 | 77 | view! { 78 | <Suspense fallback=|| ()> 79 | {move || { 80 | resource 81 | .get() 82 | .map(|config| { 83 | let images = config.cache; 84 | let handler_path = config.api_handler_path; 85 | let opt_image = opt_image.get_value().get_url_encoded(&handler_path); 86 | if blur { 87 | let placeholder_svg = images 88 | .iter() 89 | .find(|(c, _)| blur_image.with_value(|b| b == c)) 90 | .map(|c| c.1.clone()); 91 | let svg = { 92 | if let Some(svg_data) = placeholder_svg { 93 | SvgImage::InMemory(svg_data) 94 | } else { 95 | SvgImage::Request( 96 | blur_image.get_value().get_url_encoded(&handler_path), 97 | ) 98 | } 99 | }; 100 | let class = class.get_value(); 101 | let alt = alt.get_value(); 102 | view! { <CacheImage lazy svg opt_image alt class=class priority/> } 103 | .into_view() 104 | } else { 105 | let loading = if lazy { "lazy" } else { "eager" }; 106 | view! { 107 | <img 108 | alt=alt.get_value() 109 | class=class.get_value() 110 | decoding="async" 111 | loading=loading 112 | src=opt_image 113 | /> 114 | } 115 | .into_view() 116 | } 117 | }) 118 | }} 119 | 120 | </Suspense> 121 | } 122 | } 123 | 124 | enum SvgImage { 125 | InMemory(String), 126 | Request(String), 127 | } 128 | 129 | #[component] 130 | fn CacheImage( 131 | svg: SvgImage, 132 | #[prop(into)] opt_image: String, 133 | #[prop(into, optional)] alt: String, 134 | class: Option<Attribute>, 135 | priority: bool, 136 | lazy: bool, 137 | ) -> impl IntoView { 138 | use base64::{engine::general_purpose, Engine as _}; 139 | 140 | let style = { 141 | let background_image = match svg { 142 | SvgImage::InMemory(svg_data) => { 143 | let svg_encoded = general_purpose::STANDARD.encode(svg_data.as_bytes()); 144 | format!("url('data:image/svg+xml;base64,{svg_encoded}')") 145 | } 146 | SvgImage::Request(svg_url) => { 147 | format!("url('{}')", svg_url) 148 | } 149 | }; 150 | let style= format!( 151 | "color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:{background_image};", 152 | ); 153 | 154 | style 155 | }; 156 | 157 | let loading = if lazy { "lazy" } else { "eager" }; 158 | 159 | view! { 160 | {if priority { 161 | view! { <Link rel="preload" as_="image" href=opt_image.clone()/> }.into_view() 162 | } else { 163 | ().into_view() 164 | }} 165 | 166 | <img 167 | alt=alt.clone() 168 | class=class 169 | decoding="async" 170 | loading=loading 171 | src=opt_image 172 | style=style 173 | /> 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![warn(missing_docs)] 3 | 4 | //! # Leptos Image 5 | //! 6 | //! > Crafted with inspiration from Next.js 7 | //! 8 | //! Images make a substantial impact on the size and performance of a website, so why not get them right? 9 | //! 10 | //! Enter Leptos `<Image/>`, a component that enhances the standard HTML `<img>` element with automatic image optimization features. 11 | //! 12 | //! ## Features 13 | //! 14 | //! - **Size Optimization**: Automatically resizes images and converts them to the modern `.webp` format for an ideal balance of size and quality. 15 | //! - **Low-Quality Image Placeholders (LQIP)**: Embeds SVG placeholders extracted from original images directly into your server-side rendered HTML, improving perceived performance by displaying content while the full-quality image loads. 16 | //! - **Faster Page Load**: Prioritizes key images that impact the Largest Contentful Paint (LCP) with the `priority` prop, injecting a preload `<link>` into the document head to accelerate load times. 17 | //! 18 | //! ## Getting Started 19 | //! 20 | //! The crate focuses on creating optimized images for static content in Leptos projects, a full-stack web framework in Rust. 21 | //! 22 | //! ### Setup Process 23 | //! 24 | //! 1. **Provide Image Context**: Initialize your Leptos application with `leptos_image::provide_image_context` to grant it read access to the image cache. 25 | //! ``` 26 | //! use leptos::*; 27 | //! 28 | //! #[component] 29 | //! fn App() -> impl IntoView { 30 | //! leptos_image::provide_image_context(); 31 | //! // Your app content here 32 | //! } 33 | //! ``` 34 | //! 2. **Integrate with Leptos Routes**: Ensure your router includes the `ImageOptimizer` context when setting up Leptos routes. 35 | //! 3. **Axum State Configuration**: Incorporate `ImageOptimizer` into your app's Axum state for centralized management. 36 | //! 4. **Cache Route Configuration**: Add a dedicated route to your router for serving optimized images from the cache. 37 | //! 38 | //! ### Example Implementation 39 | //! 40 | //! Here’s how you can integrate the Image Optimizer into your Leptos application: 41 | //! 42 | //! ``` 43 | //! 44 | //! # use leptos_image::*; 45 | //! # use leptos::*; 46 | //! # use axum::*; 47 | //! # use axum::routing::post; 48 | //! # use leptos_axum::{generate_route_list, handle_server_fns, LeptosRoutes}; 49 | //! 50 | //! #[cfg(feature = "ssr")] 51 | //! async fn your_main_function() { 52 | //! let options = get_configuration(None).await.unwrap().leptos_options; 53 | //! let optimizer = ImageOptimizer::new("/__cache/image", options.site_root.clone(), 1); 54 | //! let state = AppState { leptos_options: options, optimizer: optimizer.clone() }; 55 | //! 56 | //! let router: Router<()> = Router::new() 57 | //! .route("/api/*fn_name", post(leptos_axum::handle_server_fns)) 58 | //! // Adding cache route 59 | //! .image_cache_route(&state) 60 | //! // Provide the optimizer to Leptos context 61 | //! .leptos_routes_with_context(&state, generate_route_list(App), optimizer.provide_context(), App) 62 | //! .with_state(state); 63 | //! 64 | //! // Rest of your function... 65 | //! } 66 | //! 67 | //! // Composite App State with the optimizer and Leptos options. 68 | //! #[derive(Clone, axum::extract::FromRef)] 69 | //! struct AppState { 70 | //! leptos_options: leptos::LeptosOptions, 71 | //! optimizer: leptos_image::ImageOptimizer, 72 | //! } 73 | //! 74 | //! #[component] 75 | //! fn App() -> impl IntoView { 76 | //! provide_image_context(); 77 | //! // Your app content here 78 | //! } 79 | //! ``` 80 | //! 81 | //! This setup ensures your Leptos application is fully equipped to deliver optimized images, enhancing the performance and user experience of your web projects. 82 | //! 83 | //! Now you can use the Image Component anywhere in your app! 84 | 85 | //! ``` 86 | //! use leptos::*; 87 | //! use leptos_image::*; 88 | //! 89 | //! #[component] 90 | //! pub fn MyImage() -> impl IntoView { 91 | //! view! { 92 | //! <Image 93 | //! src="/cute_ferris.png" 94 | //! blur=true 95 | //! width=750 96 | //! height=500 97 | //! quality=85 98 | //! /> 99 | //! } 100 | //! } 101 | //! ``` 102 | //! 103 | 104 | mod image; 105 | mod optimizer; 106 | mod provider; 107 | #[cfg(feature = "ssr")] 108 | mod routes; 109 | 110 | pub use image::*; 111 | #[cfg(feature = "ssr")] 112 | pub use optimizer::ImageOptimizer; 113 | pub use provider::*; 114 | #[cfg(feature = "ssr")] 115 | pub use routes::*; 116 | -------------------------------------------------------------------------------- /src/optimizer.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// ImageOptimizer enables image optimization and caching. 4 | #[cfg(feature = "ssr")] 5 | #[derive(Debug, Clone)] 6 | pub struct ImageOptimizer { 7 | pub(crate) api_handler_path: String, 8 | pub(crate) root_file_path: String, 9 | pub(crate) semaphore: std::sync::Arc<tokio::sync::Semaphore>, 10 | pub(crate) cache: std::sync::Arc<dashmap::DashMap<CachedImage, String>>, 11 | } 12 | 13 | #[cfg(feature = "ssr")] 14 | impl ImageOptimizer { 15 | /// Creates a new ImageOptimizer. 16 | /// api_handler_path is the path where the image handler is located in the server router. 17 | /// Parallelism denotes the number of images that can be created at once. 18 | /// Useful to limit to prevent overloading the server. 19 | pub fn new( 20 | api_handler_path: impl Into<String>, 21 | root_file_path: impl Into<String>, 22 | parallelism: usize, 23 | ) -> Self { 24 | let semaphore = tokio::sync::Semaphore::new(parallelism); 25 | let semaphore = std::sync::Arc::new(semaphore); 26 | Self { 27 | api_handler_path: api_handler_path.into(), 28 | root_file_path: root_file_path.into(), 29 | semaphore, 30 | cache: std::sync::Arc::new(dashmap::DashMap::new()), 31 | } 32 | } 33 | 34 | /// Creates a context function to provide the optimizer. 35 | /// 36 | /// ``` 37 | /// use leptos_image::*; 38 | /// use leptos::*; 39 | /// use axum::*; 40 | /// use axum::routing::post; 41 | /// use leptos_axum::{generate_route_list, handle_server_fns, LeptosRoutes}; 42 | /// 43 | /// #[cfg(feature = "ssr")] 44 | /// async fn your_main_function() { 45 | /// 46 | /// let options = get_configuration(None).await.unwrap().leptos_options; 47 | /// let optimizer = ImageOptimizer::new("/__cache/image", options.site_root.clone(), 1); 48 | /// let state = AppState {leptos_options: options, optimizer: optimizer.clone() }; 49 | /// let routes = generate_route_list(App); 50 | /// 51 | /// let router: Router<()> = Router::new() 52 | /// .route("/api/*fn_name", post(leptos_axum::handle_server_fns)) 53 | /// .image_cache_route(&state) 54 | /// // Use provide_context() 55 | /// .leptos_routes_with_context(&state, routes, optimizer.provide_context(), App) 56 | /// .with_state(state); 57 | /// 58 | /// // Rest of your function ... 59 | /// } 60 | /// 61 | /// // Composite App State with the optimizer and leptos options. 62 | /// #[derive(Clone, axum::extract::FromRef)] 63 | /// struct AppState { 64 | /// leptos_options: leptos::LeptosOptions, 65 | /// optimizer: leptos_image::ImageOptimizer, 66 | /// } 67 | /// 68 | /// #[component] 69 | /// fn App() -> impl IntoView { 70 | /// () 71 | /// } 72 | /// ``` 73 | pub fn provide_context(&self) -> impl Fn() + 'static + Clone + Send { 74 | let optimizer = self.clone(); 75 | move || { 76 | leptos::provide_context(optimizer.clone()); 77 | } 78 | } 79 | 80 | pub(crate) async fn create_image( 81 | &self, 82 | cache_image: &CachedImage, 83 | ) -> Result<bool, CreateImageError> { 84 | let root = self.root_file_path.as_str(); 85 | { 86 | let option = if let CachedImageOption::Resize(_) = cache_image.option { 87 | "Resize" 88 | } else { 89 | "Blur" 90 | }; 91 | tracing::debug!("Creating {option} image for {}", &cache_image.src); 92 | } 93 | 94 | let relative_path_created = self.get_file_path(&cache_image); 95 | 96 | let save_path = path_from_segments(vec![root, &relative_path_created]); 97 | let absolute_src_path = path_from_segments(vec![root, &cache_image.src]); 98 | 99 | if file_exists(&save_path).await { 100 | Ok(false) 101 | } else { 102 | let _ = self 103 | .semaphore 104 | .acquire() 105 | .await 106 | .expect("Failed to acquire semaphore"); 107 | let task = tokio::task::spawn_blocking({ 108 | let option = cache_image.option.clone(); 109 | move || create_optimized_image(option, absolute_src_path, save_path) 110 | }); 111 | 112 | match task.await { 113 | Err(join_error) => Err(CreateImageError::JoinError(join_error)), 114 | Ok(Err(err)) => Err(err), 115 | Ok(Ok(_)) => Ok(true), 116 | } 117 | } 118 | } 119 | 120 | #[cfg(feature = "ssr")] 121 | pub(crate) fn get_file_path_from_root(&self, cache_image: &CachedImage) -> String { 122 | let path = path_from_segments(vec![ 123 | self.root_file_path.as_ref(), 124 | &self.get_file_path(cache_image), 125 | ]); 126 | path.as_path().to_string_lossy().to_string() 127 | } 128 | 129 | pub(crate) fn get_file_path(&self, cache_image: &CachedImage) -> String { 130 | use base64::{engine::general_purpose, Engine as _}; 131 | // I'm worried this name will become too long. 132 | // names are limited to 255 bytes on most filesystems. 133 | 134 | let encode = serde_qs::to_string(&cache_image).unwrap(); 135 | let encode = general_purpose::STANDARD.encode(encode); 136 | 137 | let mut path = path_from_segments(vec!["cache/image", &encode, &cache_image.src]); 138 | 139 | if let CachedImageOption::Resize { .. } = cache_image.option { 140 | path.set_extension("webp"); 141 | } else { 142 | path.set_extension("svg"); 143 | }; 144 | 145 | path.as_path().to_string_lossy().to_string() 146 | } 147 | } 148 | 149 | #[cfg(feature = "ssr")] 150 | fn create_optimized_image<P>( 151 | config: CachedImageOption, 152 | source_path: P, 153 | save_path: P, 154 | ) -> Result<(), CreateImageError> 155 | where 156 | P: AsRef<std::path::Path> + AsRef<std::ffi::OsStr>, 157 | { 158 | use webp::*; 159 | 160 | match config { 161 | CachedImageOption::Resize(Resize { 162 | width, 163 | height, 164 | quality, 165 | }) => { 166 | let img = image::open(source_path)?; 167 | let new_img = img.resize( 168 | width, 169 | height, 170 | // Cubic Filter. 171 | image::imageops::FilterType::CatmullRom, 172 | ); 173 | // Create the WebP encoder for the above image 174 | let encoder: Encoder = Encoder::from_image(&new_img).unwrap(); 175 | // Encode the image at a specified quality 0-100 176 | let webp: WebPMemory = encoder.encode(quality as f32); 177 | create_nested_if_needed(&save_path)?; 178 | std::fs::write(save_path, &*webp)?; 179 | 180 | Ok(()) 181 | } 182 | CachedImageOption::Blur(blur) => { 183 | let svg = create_image_blur(source_path, blur)?; 184 | create_nested_if_needed(&save_path)?; 185 | std::fs::write(save_path, &*svg)?; 186 | Ok(()) 187 | } 188 | } 189 | } 190 | 191 | #[cfg(feature = "ssr")] 192 | fn create_image_blur<P>(source_path: P, blur: Blur) -> Result<String, CreateImageError> 193 | where 194 | P: AsRef<std::path::Path> + AsRef<std::ffi::OsStr>, 195 | { 196 | use webp::*; 197 | 198 | let img = image::open(source_path).map_err(|e| CreateImageError::ImageError(e))?; 199 | 200 | let Blur { 201 | width, 202 | height, 203 | svg_height, 204 | svg_width, 205 | sigma, 206 | } = blur; 207 | 208 | let img = img.resize(width, height, image::imageops::FilterType::Nearest); 209 | 210 | // Create the WebP encoder for the above image 211 | let encoder: Encoder = Encoder::from_image(&img).unwrap(); 212 | // Encode the image at a specified quality 0-100 213 | let webp: WebPMemory = encoder.encode(80.0); 214 | 215 | // Encode the image to base64 216 | use base64::{engine::general_purpose, Engine as _}; 217 | let encoded = general_purpose::STANDARD.encode(&*webp); 218 | 219 | let uri = format!("data:image/webp;base64,{}", encoded); 220 | 221 | let svg = format!( 222 | r#" 223 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 {svg_width} {svg_height}" preserveAspectRatio="none"> 224 | <filter id="a" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 225 | <feGaussianBlur stdDeviation="{sigma}" edgeMode="duplicate"/> 226 | <feComponentTransfer> 227 | <feFuncA type="discrete" tableValues="1 1"/> 228 | </feComponentTransfer> 229 | </filter> 230 | <image filter="url(#a)" x="0" y="0" height="100%" width="100%" href="{uri}"/> 231 | </svg> 232 | "#, 233 | ); 234 | 235 | Ok(svg) 236 | } 237 | 238 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, Hash)] 239 | pub struct CachedImage { 240 | pub(crate) src: String, 241 | pub(crate) option: CachedImageOption, 242 | } 243 | 244 | impl std::fmt::Display for CachedImage { 245 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 246 | match &self.option { 247 | CachedImageOption::Resize(resize) => write!( 248 | f, 249 | "ImageResize {} ({}x{} @ {}% quality)", 250 | self.src, resize.width, resize.height, resize.quality, 251 | ), 252 | CachedImageOption::Blur(_) => write!(f, "ImageBlur {}", self.src), 253 | } 254 | } 255 | } 256 | 257 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, Hash)] 258 | pub(crate) enum CachedImageOption { 259 | #[serde(rename = "r")] 260 | Resize(Resize), 261 | #[serde(rename = "b")] 262 | Blur(Blur), 263 | } 264 | 265 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, Hash)] 266 | #[serde(rename = "r")] 267 | pub(crate) struct Resize { 268 | #[serde(rename = "w")] 269 | pub width: u32, 270 | #[serde(rename = "h")] 271 | pub height: u32, 272 | #[serde(rename = "q")] 273 | pub quality: u8, 274 | } 275 | 276 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, Hash)] 277 | #[serde(rename = "b")] 278 | pub(crate) struct Blur { 279 | #[serde(rename = "w")] 280 | pub width: u32, 281 | #[serde(rename = "h")] 282 | pub height: u32, 283 | #[serde(rename = "sw")] 284 | pub svg_width: u32, 285 | #[serde(rename = "sh")] 286 | pub svg_height: u32, 287 | #[serde(rename = "s")] 288 | pub sigma: u8, 289 | } 290 | 291 | #[cfg(feature = "ssr")] 292 | #[derive(Debug, thiserror::Error)] 293 | pub enum CreateImageError { 294 | // Unexpected(String), 295 | #[error("Image Error: {0}")] 296 | ImageError(#[from] image::ImageError), 297 | #[error("Join Error: {0}")] 298 | JoinError(#[from] tokio::task::JoinError), 299 | #[error("IO Error: {0}")] 300 | IOError(#[from] std::io::Error), 301 | } 302 | 303 | impl CachedImage { 304 | pub(crate) fn get_url_encoded(&self, handler_path: impl AsRef<str>) -> String { 305 | let params = serde_qs::to_string(&self).unwrap(); 306 | format!("{}?{}", handler_path.as_ref(), params) 307 | } 308 | 309 | #[cfg(feature = "ssr")] 310 | pub(crate) fn get_file_path(&self) -> String { 311 | use base64::{engine::general_purpose, Engine as _}; 312 | // I'm worried this name will become too long. 313 | // names are limited to 255 bytes on most filesystems. 314 | 315 | let encode = serde_qs::to_string(&self).unwrap(); 316 | let encode = general_purpose::STANDARD.encode(encode); 317 | 318 | let mut path = path_from_segments(vec!["cache/image", &encode, &self.src]); 319 | 320 | if let CachedImageOption::Resize { .. } = self.option { 321 | path.set_extension("webp"); 322 | } else { 323 | path.set_extension("svg"); 324 | }; 325 | 326 | path.as_path().to_string_lossy().to_string() 327 | } 328 | 329 | #[allow(dead_code)] 330 | #[cfg(feature = "ssr")] 331 | // TODO: Fix this. Super Yuck. 332 | pub(crate) fn from_file_path(path: &str) -> Option<Self> { 333 | use base64::{engine::general_purpose, Engine as _}; 334 | path.split('/') 335 | .filter_map(|s| { 336 | general_purpose::STANDARD 337 | .decode(s) 338 | .ok() 339 | .and_then(|s| String::from_utf8(s).ok()) 340 | }) 341 | .find_map(|encoded| serde_qs::from_str(&encoded).ok()) 342 | } 343 | 344 | #[cfg(feature = "ssr")] 345 | pub(crate) fn from_url_encoded(url: &str) -> Result<CachedImage, serde_qs::Error> { 346 | let url = url.split('?').filter(|s| *s != "?").last().unwrap_or(url); 347 | let result: Result<CachedImage, serde_qs::Error> = serde_qs::from_str(url); 348 | result 349 | } 350 | } 351 | 352 | #[cfg(feature = "ssr")] 353 | fn path_from_segments(segments: Vec<&str>) -> std::path::PathBuf { 354 | segments 355 | .into_iter() 356 | .map(|s| s.trim_start_matches('/')) 357 | .map(|s| s.trim_end_matches('/')) 358 | .filter(|s| !s.is_empty()) 359 | .collect() 360 | } 361 | 362 | #[cfg(feature = "ssr")] 363 | async fn file_exists<P>(path: P) -> bool 364 | where 365 | P: AsRef<std::path::Path>, 366 | { 367 | tokio::fs::metadata(path).await.is_ok() 368 | } 369 | 370 | #[cfg(feature = "ssr")] 371 | fn create_nested_if_needed<P>(path: P) -> std::io::Result<()> 372 | where 373 | P: AsRef<std::ffi::OsStr>, 374 | { 375 | match std::path::Path::new(&path).parent() { 376 | Some(p) if (!(p).exists()) => std::fs::create_dir_all(p), 377 | Some(_) => Result::Ok(()), 378 | None => Result::Ok(()), 379 | } 380 | } 381 | 382 | // Test module 383 | #[cfg(test)] 384 | mod optimizer_tests { 385 | use super::*; 386 | 387 | #[test] 388 | fn url_encode() { 389 | let img = CachedImage { 390 | src: "test.jpg".to_string(), 391 | option: CachedImageOption::Resize(Resize { 392 | quality: 75, 393 | width: 100, 394 | height: 100, 395 | }), 396 | }; 397 | 398 | let encoded = img.get_url_encoded("/cache/image/test"); 399 | let decoded: CachedImage = CachedImage::from_url_encoded(&encoded).unwrap(); 400 | 401 | dbg!(encoded); 402 | assert!(img == decoded); 403 | } 404 | 405 | const TEST_IMAGE: &str = "./example/start-axum/public/cute_ferris.png"; 406 | 407 | #[test] 408 | fn file_path() { 409 | let spec = CachedImage { 410 | src: TEST_IMAGE.to_string(), 411 | option: CachedImageOption::Blur(Blur { 412 | width: 25, 413 | height: 25, 414 | svg_height: 100, 415 | svg_width: 100, 416 | sigma: 20, 417 | }), 418 | }; 419 | 420 | let file_path = spec.get_file_path(); 421 | 422 | dbg!(spec.get_file_path()); 423 | 424 | let result = CachedImage::from_file_path(&file_path).unwrap(); 425 | 426 | assert_eq!(spec, result); 427 | } 428 | 429 | #[test] 430 | fn create_blur() { 431 | let result = create_image_blur( 432 | TEST_IMAGE.to_string(), 433 | Blur { 434 | width: 25, 435 | height: 25, 436 | svg_height: 100, 437 | svg_width: 100, 438 | sigma: 20, 439 | }, 440 | ); 441 | assert!(result.is_ok()); 442 | println!("{}", result.unwrap()); 443 | } 444 | 445 | #[test] 446 | fn create_and_save_blur() { 447 | let spec = CachedImage { 448 | src: TEST_IMAGE.to_string(), 449 | option: CachedImageOption::Blur(Blur { 450 | width: 25, 451 | height: 25, 452 | svg_height: 100, 453 | svg_width: 100, 454 | sigma: 20, 455 | }), 456 | }; 457 | 458 | let file_path = spec.get_file_path(); 459 | 460 | let result = create_optimized_image(spec.option, TEST_IMAGE.to_string(), file_path.clone()); 461 | 462 | assert!(result.is_ok()); 463 | 464 | println!("Saved SVG at {file_path}"); 465 | } 466 | 467 | #[test] 468 | fn create_opt_image() { 469 | let spec = CachedImage { 470 | src: TEST_IMAGE.to_string(), 471 | option: CachedImageOption::Resize(Resize { 472 | quality: 75, 473 | width: 100, 474 | height: 100, 475 | }), 476 | }; 477 | 478 | let file_path = spec.get_file_path(); 479 | 480 | let result = create_optimized_image(spec.option, TEST_IMAGE.to_string(), file_path.clone()); 481 | 482 | assert!(result.is_ok()); 483 | 484 | println!("Saved WebP at {file_path}"); 485 | } 486 | } 487 | -------------------------------------------------------------------------------- /src/provider.rs: -------------------------------------------------------------------------------- 1 | use crate::optimizer::CachedImage; 2 | use leptos::*; 3 | 4 | /// Provides Image Cache Context so that Images can use their blur placeholders if they exist. 5 | /// 6 | /// This should go in the base of your Leptos <App/>. 7 | /// 8 | /// Example 9 | /// 10 | /// ``` 11 | /// use leptos::*; 12 | /// 13 | /// #[component] 14 | /// pub fn App() -> impl IntoView { 15 | /// leptos_image::provide_image_context(); 16 | /// 17 | /// view!{ 18 | /// <div/> 19 | /// } 20 | /// } 21 | /// 22 | /// ``` 23 | pub fn provide_image_context() { 24 | let resource: ImageResource = create_blocking_resource( 25 | || (), 26 | |_| async { 27 | get_image_config() 28 | .await 29 | .expect("Failed to retrieve image cache") 30 | }, 31 | ); 32 | 33 | leptos::provide_context(resource); 34 | } 35 | 36 | type ImageResource = Resource<(), ImageConfig>; 37 | 38 | #[doc(hidden)] 39 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 40 | pub struct ImageConfig { 41 | pub(crate) api_handler_path: String, 42 | pub(crate) cache: Vec<(CachedImage, String)>, 43 | } 44 | 45 | pub(crate) fn use_image_cache_resource() -> ImageResource { 46 | use_context::<ImageResource>().expect("Missing Image Resource") 47 | } 48 | 49 | #[server(GetImageCache)] 50 | pub(crate) async fn get_image_config() -> Result<ImageConfig, ServerFnError> { 51 | let optimizer = use_optimizer()?; 52 | 53 | let cache = optimizer 54 | .cache 55 | .iter() 56 | .map(|entry| (entry.key().clone(), entry.value().clone())) 57 | .collect(); 58 | 59 | let api_handler_path = optimizer.api_handler_path.clone(); 60 | 61 | Ok(ImageConfig { 62 | api_handler_path, 63 | cache, 64 | }) 65 | } 66 | 67 | #[cfg(feature = "ssr")] 68 | pub(crate) fn use_optimizer() -> Result<crate::ImageOptimizer, ServerFnError> { 69 | use_context::<crate::ImageOptimizer>() 70 | .ok_or_else(|| ServerFnError::ServerError("Image Optimizer Missing.".into())) 71 | } 72 | -------------------------------------------------------------------------------- /src/routes.rs: -------------------------------------------------------------------------------- 1 | use crate::optimizer::{CachedImage, CachedImageOption, CreateImageError, ImageOptimizer}; 2 | use axum::extract::FromRef; 3 | use axum::response::Response as AxumResponse; 4 | use axum::{ 5 | body::Body, 6 | http::{Request, Response, Uri}, 7 | response::IntoResponse, 8 | }; 9 | use std::convert::Infallible; 10 | use tower::ServiceExt; 11 | use tower_http::services::fs::ServeFileSystemResponseBody; 12 | use tower_http::services::ServeDir; 13 | 14 | /// This trait prevents using incorrect route for image cache handler. 15 | pub trait ImageCacheRoute<S> 16 | where 17 | S: Clone + Send + Sync + 'static, 18 | { 19 | /// Adds a route to the app for serving cached images. 20 | /// Requires an axum State that contains the optimizer [`crate::ImageOptimizer`]. 21 | /// 22 | /// ``` 23 | /// use leptos_image::*; 24 | /// use leptos::*; 25 | /// use axum::*; 26 | /// use axum::routing::post; 27 | /// use leptos_axum::{generate_route_list, handle_server_fns, LeptosRoutes}; 28 | /// 29 | /// #[cfg(feature = "ssr")] 30 | /// async fn your_main_function() { 31 | /// 32 | /// let options = get_configuration(None).await.unwrap().leptos_options; 33 | /// let optimizer = ImageOptimizer::new("/__cache/image", options.site_root.clone(), 1); 34 | /// let state = AppState {leptos_options: options, optimizer: optimizer.clone() }; 35 | /// let routes = generate_route_list(App); 36 | /// 37 | /// let router: Router<()> = Router::new() 38 | /// .route("/api/*fn_name", post(leptos_axum::handle_server_fns)) 39 | /// // Add a handler for serving the cached images. 40 | /// .image_cache_route(&state) 41 | /// .leptos_routes_with_context(&state, routes, optimizer.provide_context(), App) 42 | /// .with_state(state); 43 | /// 44 | /// // Rest of your function ... 45 | /// } 46 | /// 47 | /// // Composite App State with the optimizer and leptos options. 48 | /// #[derive(Clone, axum::extract::FromRef)] 49 | /// struct AppState { 50 | /// leptos_options: leptos::LeptosOptions, 51 | /// optimizer: leptos_image::ImageOptimizer, 52 | /// } 53 | /// 54 | /// #[component] 55 | /// fn App() -> impl IntoView { 56 | /// provide_image_context(); 57 | /// () 58 | /// } 59 | /// 60 | /// ``` 61 | /// 62 | /// 63 | fn image_cache_route(self, state: &S) -> Self; 64 | } 65 | 66 | impl<S> ImageCacheRoute<S> for axum::Router<S> 67 | where 68 | S: Clone + Send + Sync + 'static, 69 | ImageOptimizer: FromRef<S>, 70 | { 71 | fn image_cache_route(self, state: &S) -> Self { 72 | let optimizer = ImageOptimizer::from_ref(state); 73 | 74 | let path = optimizer.api_handler_path.clone(); 75 | let handler = move |req: Request<Body>| image_cache_handler_inner(optimizer, req); 76 | 77 | self.route(&path, axum::routing::get(handler)) 78 | } 79 | } 80 | 81 | async fn image_cache_handler_inner(optimizer: ImageOptimizer, req: Request<Body>) -> AxumResponse { 82 | let root = optimizer.root_file_path.clone(); 83 | let cache_result = check_cache_image(&optimizer, req.uri().clone()).await; 84 | 85 | match cache_result { 86 | Ok(Some(uri)) => { 87 | let response = execute_file_handler(uri, &root).await.unwrap(); 88 | response.into_response() 89 | } 90 | 91 | Ok(None) => Response::builder() 92 | .status(404) 93 | .body("Invalid Image.".to_string()) 94 | .unwrap() 95 | .into_response(), 96 | 97 | Err(e) => { 98 | tracing::error!("Failed to create image: {:?}", e); 99 | Response::builder() 100 | .status(500) 101 | .body("Error creating image".to_string()) 102 | .unwrap() 103 | .into_response() 104 | } 105 | } 106 | } 107 | 108 | async fn execute_file_handler( 109 | uri: Uri, 110 | root: &str, 111 | ) -> Result<Response<ServeFileSystemResponseBody>, Infallible> { 112 | let req = Request::builder() 113 | .uri(uri.clone()) 114 | .body(Body::empty()) 115 | .unwrap(); 116 | ServeDir::new(root).oneshot(req).await 117 | } 118 | 119 | async fn check_cache_image( 120 | optimizer: &ImageOptimizer, 121 | uri: Uri, 122 | ) -> Result<Option<Uri>, CreateImageError> { 123 | let cache_image = { 124 | let url = uri.to_string(); 125 | 126 | if let Some(img) = CachedImage::from_url_encoded(&url).ok() { 127 | let result = optimizer.create_image(&img).await; 128 | 129 | if let Ok(true) = result { 130 | tracing::info!("Created Image: {}", img); 131 | } 132 | 133 | result?; 134 | 135 | img 136 | } else { 137 | return Ok(None); 138 | } 139 | }; 140 | 141 | let file_path = cache_image.get_file_path(); 142 | 143 | add_file_to_cache(optimizer, cache_image).await; 144 | 145 | let uri_string = "/".to_string() + &file_path; 146 | let maybe_uri = (uri_string).parse::<Uri>().ok(); 147 | 148 | if let Some(uri) = maybe_uri { 149 | Ok(Some(uri)) 150 | } else { 151 | tracing::error!("Failed to create uri: File path {file_path}"); 152 | Ok(None) 153 | } 154 | } 155 | 156 | // When the image is created, it will be added to the cache. 157 | // Mostly helpful for dev server startup. 158 | async fn add_file_to_cache(optimizer: &ImageOptimizer, image: CachedImage) { 159 | if let CachedImageOption::Blur(_) = image.option { 160 | if optimizer.cache.get(&image).is_none() { 161 | let path = optimizer.get_file_path_from_root(&image); 162 | match tokio::fs::read_to_string(path).await { 163 | Ok(data) => { 164 | optimizer.cache.insert(image, data); 165 | tracing::debug!("Added image to cache (size {})", optimizer.cache.len()) 166 | } 167 | Err(e) => { 168 | tracing::error!("Failed to read image [{}] with error: {:?}", image, e); 169 | } 170 | } 171 | } 172 | } 173 | } 174 | --------------------------------------------------------------------------------