瀏覽代碼

Fix base path on web and fullstack (#3247)

* Create a crate for constant serialization of config structs for manganis

* use SerializeConst for the image asset builder

* switch to a declarative macro for assets

* clean up asset macro a bit

* add serializable options for each asset type

* serialize const vec

* Add unique path formatting

* implement the new manganis macro

* optimize assets in the CLI

* Fix clippy

* Fix assets with dioxus formattable

* reduce fuzzing test limits

* use the new syntax in examples

* fix formatting

* Final doc and test pass on const-serialize

* fix avif support

* Fix manganis doc tests

* cache asset optimization

* Split out asset and bundled asset

* make hash pointer width independent

* remove const serialize cargo lock

* Fix manganis macro docs

* all tests passing

* add constvec::is_empty method to fix clippy lint

* remove nasm feature

* simplify test_rsplit_once test so typos passes

* fix range syntax for stable

* revert example change from clippy fix

* remove dioxus-static-site-generation workspace dependency

* always accept unix paths

* fix windows path seperator

* fix folder asset hash

* Optimize assets in a blocking task

* Fix asset options docs

* Document Asset and BundledAsset

* move manganis back into it's own folder

* simplify the linker macro a bit

* add more docs to AssetParser expansion

* fix manganis core doc test

* add image format helpers

* Fill in new cargo.tomls

* fix folders with explicit options

* Split by  both unix and windows path separators and take the smallest one

* fix string length

* Fix base path on web and fullstack

* Fix assets with base path

* fix fullstack base route with trailing slash

* fix clippy
Evan Almloff 7 月之前
父節點
當前提交
3d62fbf021

+ 5 - 0
Cargo.lock

@@ -3368,6 +3368,7 @@ version = "0.6.0-alpha.5"
 dependencies = [
  "criterion",
  "dioxus",
+ "dioxus-cli-config",
  "dioxus-config-macro",
  "dioxus-core",
  "dioxus-core-macro",
@@ -3570,6 +3571,9 @@ dependencies = [
 [[package]]
 name = "dioxus-cli-config"
 version = "0.6.0-alpha.5"
+dependencies = [
+ "wasm-bindgen",
+]
 
 [[package]]
 name = "dioxus-config-macro"
@@ -7715,6 +7719,7 @@ version = "0.6.0-alpha.5"
 dependencies = [
  "const-serialize",
  "dioxus",
+ "dioxus-cli-config",
  "dioxus-core-types",
  "manganis",
  "serde",

+ 4 - 0
packages/cli-config/Cargo.toml

@@ -10,3 +10,7 @@ description = "CLI Configuration for dioxus-cli"
 keywords = ["dom", "ui", "gui", "react", ]
 
 [dependencies]
+wasm-bindgen = { workspace = true, optional = true }
+
+[features]
+web = ["dep:wasm-bindgen"]

+ 73 - 3
packages/cli-config/src/lib.rs

@@ -12,6 +12,29 @@ pub const ASSET_ROOT_ENV: &str = "DIOXUS_ASSET_ROOT";
 pub const APP_TITLE_ENV: &str = "DIOXUS_APP_TITLE";
 pub const OUT_DIR: &str = "DIOXUS_OUT_DIR";
 
+/// Reads an environment variable at runtime in debug mode or at compile time in
+/// release mode. When bundling in release mode, we will not be running under the
+/// environment variables that the CLI sets, so we need to read them at compile time.
+macro_rules! read_env_config {
+    ($name:expr) => {{
+        #[cfg(debug_assertions)]
+        {
+            // In debug mode, read the environment variable set by the CLI at runtime
+            std::env::var($name).ok()
+        }
+
+        #[cfg(not(debug_assertions))]
+        {
+            // In release mode, read the environment variable set by the CLI at compile time
+            // This means the value will still be available when running the application
+            // standalone.
+            // We don't always read the environment variable at compile time to avoid rebuilding
+            // this crate when the environment variable changes.
+            option_env!($name).map(ToString::to_string)
+        }
+    }};
+}
+
 /// Get the address of the devserver for use over a raw socket
 ///
 /// This is not a websocket! There's no protocol!
@@ -51,7 +74,7 @@ pub fn fullstack_address_or_localhost() -> SocketAddr {
 }
 
 pub fn app_title() -> Option<String> {
-    std::env::var(APP_TITLE_ENV).ok()
+    read_env_config!("DIOXUS_APP_TITLE")
 }
 
 pub fn always_on_top() -> Option<bool> {
@@ -64,8 +87,55 @@ pub fn is_cli_enabled() -> bool {
     std::env::var(CLI_ENABLED_ENV).is_ok()
 }
 
-pub fn base_path() -> Option<PathBuf> {
-    std::env::var("DIOXUS_ASSET_ROOT").ok().map(PathBuf::from)
+#[cfg(feature = "web")]
+#[wasm_bindgen::prelude::wasm_bindgen(inline_js = r#"
+    export function getMetaContents(meta_name) {
+        const selector = document.querySelector(`meta[name="${meta_name}"]`);
+        if (!selector) {
+            return null;
+        }
+        return selector.content;
+    }
+"#)]
+extern "C" {
+    #[wasm_bindgen(js_name = getMetaContents)]
+    pub fn get_meta_contents(selector: &str) -> Option<String>;
+}
+
+/// Get the path where the application will be served from. This is used by the router to format the URLs.
+pub fn base_path() -> Option<String> {
+    // This may trigger when compiling to the server if you depend on another crate that pulls in
+    // the web feature. It might be better for the renderers to provide the current platform
+    // as a global context
+    #[cfg(all(feature = "web", target_arch = "wasm32"))]
+    {
+        return web_base_path();
+    }
+
+    read_env_config!("DIOXUS_ASSET_ROOT")
+}
+
+/// Get the path where the application is served from in the browser.
+#[cfg(feature = "web")]
+pub fn web_base_path() -> Option<String> {
+    // In debug mode, we get the base path from the meta element which can be hot reloaded and changed without recompiling
+    #[cfg(debug_assertions)]
+    {
+        thread_local! {
+            static BASE_PATH: std::cell::OnceCell<Option<String>> = const { std::cell::OnceCell::new() };
+        }
+        BASE_PATH.with(|f| f.get_or_init(|| get_meta_contents(ASSET_ROOT_ENV)).clone())
+    }
+
+    // In release mode, we get the base path from the environment variable
+    #[cfg(not(debug_assertions))]
+    {
+        option_env!("DIOXUS_ASSET_ROOT").map(ToString::to_string)
+    }
+}
+
+pub fn format_base_path_meta_element(base_path: &str) -> String {
+    format!(r#"<meta name="{ASSET_ROOT_ENV}" content="{base_path}">"#,)
 }
 
 pub fn out_dir() -> Option<PathBuf> {

+ 10 - 0
packages/cli/src/build/request.rs

@@ -5,6 +5,7 @@ use crate::{assets::AssetManifest, TraceSrc};
 use crate::{link::LinkAction, BuildArgs};
 use crate::{AppBundle, Platform};
 use anyhow::Context;
+use dioxus_cli_config::{APP_TITLE_ENV, ASSET_ROOT_ENV};
 use serde::Deserialize;
 use std::{
     path::{Path, PathBuf},
@@ -540,6 +541,15 @@ impl BuildRequest {
             // env_vars.push(("PATH", extended_path));
         };
 
+        // If this is a release build, bake the base path and title
+        // into the binary with env vars
+        if self.build.release {
+            if let Some(base_path) = &self.krate.config.web.app.base_path {
+                env_vars.push((ASSET_ROOT_ENV, base_path.clone()));
+            }
+            env_vars.push((APP_TITLE_ENV, self.krate.config.web.app.title.clone()));
+        }
+
         Ok(env_vars)
     }
 

+ 9 - 0
packages/cli/src/build/web.rs

@@ -1,3 +1,5 @@
+use dioxus_cli_config::format_base_path_meta_element;
+
 use crate::error::Result;
 use crate::BuildRequest;
 use std::fmt::Write;
@@ -66,6 +68,13 @@ impl BuildRequest {
             )?;
         }
 
+        // Add the base path to the head if this is a debug build
+        if self.is_dev_build() {
+            if let Some(base_path) = &self.krate.config.web.app.base_path {
+                head_resources.push_str(&format_base_path_meta_element(base_path));
+            }
+        }
+
         if !style_list.is_empty() {
             self.send_resource_deprecation_warning(style_list, ResourceType::Style);
         }

+ 9 - 3
packages/cli/src/config/web.rs

@@ -101,9 +101,15 @@ pub(crate) struct WebAppConfig {
 impl WebAppConfig {
     /// Get the normalized base path for the application with `/` trimmed from both ends. If the base path is not set, this will return `.`.
     pub(crate) fn base_path(&self) -> &str {
-        match &self.base_path {
-            Some(path) => path.trim_matches('/'),
-            None => ".",
+        let trimmed_path = self
+            .base_path
+            .as_deref()
+            .unwrap_or_default()
+            .trim_matches('/');
+        if trimmed_path.is_empty() {
+            "."
+        } else {
+            trimmed_path
         }
     }
 }

+ 11 - 3
packages/cli/src/serve/handle.rs

@@ -69,7 +69,11 @@ impl AppHandle {
         // Set the env vars that the clients will expect
         // These need to be stable within a release version (ie 0.6.0)
         let mut envs = vec![
-            ("DIOXUS_CLI_ENABLED", "true".to_string()),
+            (dioxus_cli_config::CLI_ENABLED_ENV, "true".to_string()),
+            (
+                dioxus_cli_config::APP_TITLE_ENV,
+                self.app.build.krate.config.web.app.title.clone(),
+            ),
             ("RUST_BACKTRACE", "1".to_string()),
             (
                 dioxus_cli_config::DEVSERVER_RAW_ADDR_ENV,
@@ -80,6 +84,10 @@ impl AppHandle {
             ("CARGO_MANIFEST_DIR", "".to_string()),
         ];
 
+        if let Some(base_path) = &self.app.build.krate.config.web.app.base_path {
+            envs.push((dioxus_cli_config::ASSET_ROOT_ENV, base_path.clone()));
+        }
+
         if let Some(addr) = fullstack_address {
             envs.push((dioxus_cli_config::SERVER_IP_ENV, addr.ip().to_string()));
             envs.push((dioxus_cli_config::SERVER_PORT_ENV, addr.port().to_string()));
@@ -109,7 +117,7 @@ impl AppHandle {
             Platform::Web => {
                 // Only the first build we open the web app, after that the user knows it's running
                 if open_browser {
-                    self.open_web(envs, devserver_ip);
+                    self.open_web(devserver_ip);
                 }
 
                 None
@@ -212,7 +220,7 @@ impl AppHandle {
     /// Open the web app by opening the browser to the given address.
     /// Check if we need to use https or not, and if so, add the protocol.
     /// Go to the basepath if that's set too.
-    fn open_web(&self, _envs: Vec<(&str, String)>, address: SocketAddr) {
+    fn open_web(&self, address: SocketAddr) {
         let base_path = self.app.build.krate.config.web.app.base_path.clone();
         let https = self
             .app

+ 3 - 1
packages/cli/src/serve/mod.rs

@@ -231,7 +231,9 @@ pub(crate) async fn serve_all(mut args: ServeArgs) -> Result<()> {
             }
 
             ServeUpdate::OpenApp => {
-                runner.open_existing(&devserver).await;
+                if let Err(err) = runner.open_existing(&devserver).await {
+                    tracing::error!("Failed to open app: {err}")
+                }
             }
 
             ServeUpdate::Redraw => {

+ 8 - 5
packages/cli/src/serve/runner.rs

@@ -146,12 +146,15 @@ impl AppRunner {
     }
 
     /// Open an existing app bundle, if it exists
-    pub(crate) async fn open_existing(&self, devserver: &WebServer) {
-        if let Some(address) = devserver.server_address() {
-            let url = format!("http://{address}");
-            tracing::debug!("opening url: {url}");
-            _ = open::that(url);
+    pub(crate) async fn open_existing(&mut self, devserver: &WebServer) -> Result<()> {
+        if let Some((_, app)) = self
+            .running
+            .iter_mut()
+            .find(|(platform, _)| **platform != Platform::Server)
+        {
+            app.open(devserver.devserver_address(), None, true).await?;
         }
+        Ok(())
     }
 
     pub(crate) fn attempt_hot_reload(

+ 2 - 1
packages/dioxus/Cargo.toml

@@ -29,6 +29,7 @@ dioxus-ssr = { workspace = true, optional = true }
 manganis = { workspace = true, features = ["dioxus"], optional = true }
 
 serde = { version = "1.0.136", optional = true }
+dioxus-cli-config = { workspace = true, optional = true }
 
 [target.'cfg(not(any(target_arch = "wasm32", target_os = "ios", target_os = "android")))'.dependencies]
 dioxus-devtools = { workspace = true, optional = true }
@@ -53,7 +54,7 @@ router = ["dep:dioxus-router"]
 fullstack = ["dep:dioxus-fullstack", "dioxus-config-macro/fullstack", "dep:serde"]
 desktop = ["dep:dioxus-desktop", "dioxus-fullstack?/desktop", "dioxus-config-macro/desktop"]
 mobile = ["dep:dioxus-mobile", "dioxus-fullstack?/mobile", "dioxus-config-macro/mobile"]
-web = ["dep:dioxus-web", "dioxus-fullstack?/web", "dioxus-config-macro/web"]
+web = ["dep:dioxus-web", "dioxus-fullstack?/web", "dioxus-config-macro/web", "dioxus-cli-config", "dioxus-cli-config/web"]
 ssr = ["dep:dioxus-ssr", "dioxus-config-macro/ssr"]
 liveview = ["dep:dioxus-liveview", "dioxus-config-macro/liveview"]
 server = ["dioxus-fullstack?/axum", "dioxus-fullstack?/server", "ssr", "dioxus-liveview?/axum"]

+ 13 - 0
packages/dioxus/src/launch.rs

@@ -274,6 +274,19 @@ fn web_launch(
             .unwrap_or_default()
             .hydrate(true);
 
+        // If there is a base path set, call server functions from that base path
+        if let Some(base_path) = dioxus_cli_config::web_base_path() {
+            let base_path = base_path.trim_matches('/');
+            crate::prelude::server_fn::client::set_server_url(
+                format!(
+                    "{}/{}",
+                    crate::prelude::server_fn::client::get_server_url(),
+                    base_path
+                )
+                .leak(),
+            );
+        }
+
         let factory = move || {
             let mut vdom = dioxus_core::VirtualDom::new(root);
             for context in contexts {

+ 14 - 3
packages/fullstack/src/render.rs

@@ -1,6 +1,7 @@
 //! A shared pool of renderers for efficient server side rendering.
 use crate::document::ServerDocument;
 use crate::streaming::{Mount, StreamingRenderer};
+use dioxus_cli_config::base_path;
 use dioxus_interpreter_js::INITIALIZE_STREAMING_JS;
 use dioxus_isrg::{CachedRender, RenderFreshness};
 use dioxus_lib::document::Document;
@@ -169,9 +170,19 @@ impl SsrRendererPool {
             let mut virtual_dom = virtual_dom_factory();
             let document = std::rc::Rc::new(crate::document::server::ServerDocument::default());
             virtual_dom.provide_root_context(document.clone());
-            virtual_dom.provide_root_context(Rc::new(
-                dioxus_history::MemoryHistory::with_initial_path(&route),
-            ) as Rc<dyn dioxus_history::History>);
+            // If there is a base path, trim the base path from the route and add the base path formatting to the
+            // history provider
+            let history;
+            if let Some(base_path) = base_path() {
+                let base_path = base_path.trim_matches('/');
+                let base_path = format!("/{base_path}");
+                let route = route.strip_prefix(&base_path).unwrap_or(&route);
+                history =
+                    dioxus_history::MemoryHistory::with_initial_path(route).with_prefix(base_path);
+            } else {
+                history = dioxus_history::MemoryHistory::with_initial_path(&route);
+            }
+            virtual_dom.provide_root_context(Rc::new(history) as Rc<dyn dioxus_history::History>);
             virtual_dom.provide_root_context(document.clone() as std::rc::Rc<dyn Document>);
 
             // poll the future, which may call server_context()

+ 38 - 2
packages/fullstack/src/server/launch.rs

@@ -2,8 +2,16 @@
 
 use std::any::Any;
 
+use axum::{
+    body::Body,
+    extract::{Request, State},
+    response::IntoResponse,
+};
+use dioxus_cli_config::base_path;
 use dioxus_lib::prelude::*;
 
+use crate::server::{render_handler, RenderHandleState, SSRState};
+
 /// Launch a fullstack app with the given root component, contexts, and config.
 #[allow(unused)]
 pub fn launch(
@@ -60,9 +68,37 @@ pub fn launch(
                 }
             }
 
-            #[allow(unused_mut)]
-            let mut router =
+            let mut base_path = base_path();
+            let config = platform_config.as_ref().ok().cloned();
+            let dioxus_router =
                 axum::Router::new().serve_dioxus_application(TryIntoResult(platform_config), root);
+            let mut router;
+            match base_path.as_deref() {
+                Some(base_path) => {
+                    let base_path = base_path.trim_matches('/');
+                    // If there is a base path, nest the router under it and serve the root route manually
+                    // Nesting a route in axum only serves /base_path or /base_path/ not both
+                    router = axum::Router::new().nest(&format!("/{base_path}/"), dioxus_router);
+                    async fn root_render_handler(
+                        state: State<RenderHandleState>,
+                        mut request: Request<Body>,
+                    ) -> impl IntoResponse {
+                        // The root of the base path always looks like the root from dioxus fullstack
+                        *request.uri_mut() = "/".parse().unwrap();
+                        render_handler(state, request).await
+                    }
+                    if let Some(cfg) = config {
+                        let ssr_state = SSRState::new(&cfg);
+                        router = router.route(
+                            &format!("/{base_path}"),
+                            axum::routing::method_routing::get(root_render_handler).with_state(
+                                RenderHandleState::new(cfg, root).with_ssr_state(ssr_state),
+                            ),
+                        )
+                    }
+                }
+                None => router = dioxus_router,
+            }
 
             let router = router.into_make_service();
             let listener = tokio::net::TcpListener::bind(address).await.unwrap();

+ 26 - 5
packages/history/src/memory.rs

@@ -11,6 +11,7 @@ struct MemoryHistoryState {
 /// A [`History`] provider that stores all navigation information in memory.
 pub struct MemoryHistory {
     state: RefCell<MemoryHistoryState>,
+    base_path: Option<String>,
 }
 
 impl Default for MemoryHistory {
@@ -44,16 +45,36 @@ impl MemoryHistory {
     pub fn with_initial_path(path: impl ToString) -> Self {
         Self {
             state: MemoryHistoryState{
-            current: path.to_string().parse().unwrap_or_else(|err| {
-                panic!("index route does not exist:\n{err}\n use MemoryHistory::with_initial_path to set a custom path")
-            }),
-            history: Vec::new(),
-            future: Vec::new(),}.into()
+                current: path.to_string().parse().unwrap_or_else(|err| {
+                    panic!("index route does not exist:\n{err}\n use MemoryHistory::with_initial_path to set a custom path")
+                }),
+                history: Vec::new(),
+                future: Vec::new(),
+            }.into(),
+            base_path: None,
         }
     }
+
+    /// Set the base path for the history. All routes will be prefixed with this path when rendered.
+    ///
+    /// ```rust
+    /// # use dioxus_history::*;
+    /// let mut history = MemoryHistory::default().with_prefix("/my-app");
+    ///
+    /// // The base path is set to "/my-app"
+    /// assert_eq!(history.current_prefix(), Some("/my-app".to_string()));
+    /// ```
+    pub fn with_prefix(mut self, prefix: impl ToString) -> Self {
+        self.base_path = Some(prefix.to_string());
+        self
+    }
 }
 
 impl History for MemoryHistory {
+    fn current_prefix(&self) -> Option<String> {
+        self.base_path.clone()
+    }
+
     fn current_route(&self) -> String {
         self.state.borrow().current.clone()
     }

+ 2 - 1
packages/manganis/manganis-core/Cargo.toml

@@ -16,10 +16,11 @@ keywords = ["dom", "ui", "gui", "assets"]
 serde = { workspace = true, features = ["derive"] }
 const-serialize = { workspace = true, features = ["serde"] }
 dioxus-core-types = { workspace = true, optional = true }
+dioxus-cli-config = { workspace = true, optional = true }
 
 [dev-dependencies]
 manganis = { workspace = true }
 dioxus = { workspace = true }
 
 [features]
-dioxus = ["dep:dioxus-core-types"]
+dioxus = ["dep:dioxus-core-types", "dep:dioxus-cli-config"]

+ 16 - 1
packages/manganis/manganis-core/src/asset.rs

@@ -124,8 +124,23 @@ impl Asset {
             return PathBuf::from(self.bundled.absolute_source_path.as_str());
         }
 
+        #[cfg(feature = "dioxus")]
+        let bundle_root = {
+            let base_path = dioxus_cli_config::base_path();
+            let base_path = base_path
+                .as_deref()
+                .map(|base_path| {
+                    let trimmed = base_path.trim_matches('/');
+                    format!("/{trimmed}")
+                })
+                .unwrap_or_default();
+            PathBuf::from(format!("{base_path}/assets/"))
+        };
+        #[cfg(not(feature = "dioxus"))]
+        let bundle_root = PathBuf::from("/assets/");
+
         // Otherwise presumably we're bundled and we can use the bundled path
-        PathBuf::from("/assets/").join(PathBuf::from(
+        bundle_root.join(PathBuf::from(
             self.bundled.bundled_path.as_str().trim_start_matches('/'),
         ))
     }

+ 3 - 1
packages/router/src/components/link.rs

@@ -163,6 +163,8 @@ pub fn Link(props: LinkProps) -> Element {
         NavigationTarget::Internal(url) => url.clone(),
         NavigationTarget::External(route) => route.clone(),
     };
+    // Add the history's prefix to the href for use in the rsx
+    let full_href = router.prefix().unwrap_or_default() + &href;
 
     let mut class_ = String::new();
     if let Some(c) = class {
@@ -240,7 +242,7 @@ pub fn Link(props: LinkProps) -> Element {
         a {
             onclick: action,
             "onclick": liveview_prevent_default,
-            href,
+            href: full_href,
             onmounted: onmounted,
             class,
             rel,

+ 2 - 5
packages/router/src/contexts/router.rs

@@ -47,9 +47,6 @@ pub(crate) type RoutingCallback<R> =
 pub(crate) type AnyRoutingCallback = Arc<dyn Fn(RouterContext) -> Option<NavigationTarget>>;
 
 struct RouterContextInner {
-    /// The current prefix.
-    prefix: Option<String>,
-
     unresolved_error: Option<ExternalNavigationFailure>,
 
     subscribers: Arc<Mutex<HashSet<ReactiveContext>>>,
@@ -105,7 +102,6 @@ impl RouterContext {
         let mapping = consume_child_route_mapping();
 
         let myself = RouterContextInner {
-            prefix: Default::default(),
             unresolved_error: None,
             subscribers: subscribers.clone(),
             routing_callback: cfg.on_update.map(|update| {
@@ -259,7 +255,8 @@ impl RouterContext {
 
     /// The prefix that is currently active.
     pub fn prefix(&self) -> Option<String> {
-        self.inner.read().prefix.clone()
+        let history = history();
+        history.current_prefix()
     }
 
     /// Clear any unresolved errors

+ 1 - 1
packages/web/Cargo.toml

@@ -12,7 +12,7 @@ keywords = ["dom", "ui", "gui", "react", "wasm"]
 [dependencies]
 dioxus-core = { workspace = true }
 dioxus-core-types = { workspace = true }
-dioxus-cli-config = { workspace = true }
+dioxus-cli-config = { workspace = true, features = ["web"] }
 dioxus-html = { workspace = true }
 dioxus-history = { workspace = true }
 dioxus-document = { workspace = true }

+ 5 - 6
packages/web/src/history/mod.rs

@@ -1,5 +1,4 @@
 use scroll::ScrollPosition;
-use std::path::PathBuf;
 use wasm_bindgen::JsCast;
 use wasm_bindgen::{prelude::Closure, JsValue};
 use web_sys::{window, Window};
@@ -7,9 +6,8 @@ use web_sys::{Event, History, ScrollRestoration};
 
 mod scroll;
 
-#[allow(dead_code)]
-fn base_path() -> Option<PathBuf> {
-    let base_path = dioxus_cli_config::base_path();
+fn base_path() -> Option<String> {
+    let base_path = dioxus_cli_config::web_base_path();
     tracing::trace!("Using base_path from the CLI: {:?}", base_path);
     base_path
 }
@@ -77,9 +75,10 @@ impl WebHistory {
 
         let prefix = prefix
             // If there isn't a base path, try to grab one from the CLI
-            .or_else(|| base_path().map(|s| s.display().to_string()))
+            .or_else(base_path)
             // Normalize the prefix to start and end with no slashes
-            .map(|prefix| prefix.trim_matches('/').to_string())
+            .as_ref()
+            .map(|prefix| prefix.trim_matches('/'))
             // If the prefix is empty, don't add it
             .filter(|prefix| !prefix.is_empty())
             // Otherwise, start with a slash