Skip to content

Commit

Permalink
feat(routing): ✨ added support for relative path hosting with `PERSEU…
Browse files Browse the repository at this point in the history
…S_BASE_PATH` environment variable

Closes #48.
BREAKING CHANGE: multiple *internal* function signatures accept exxtra parameter for path prefix
  • Loading branch information
arctic-hen7 committed Oct 6, 2021
1 parent 45a0f6c commit b7d6eb6
Show file tree
Hide file tree
Showing 16 changed files with 114 additions and 40 deletions.
3 changes: 2 additions & 1 deletion examples/basic/.perseus/src/bin/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use app::{
};
use fs_extra::dir::{copy as copy_dir, CopyOptions};
use futures::executor::block_on;
use perseus::{build_app, export_app, SsrNode};
use perseus::{build_app, export_app, path_prefix::get_path_prefix_server, SsrNode};
use std::fs;
use std::path::PathBuf;

Expand Down Expand Up @@ -41,6 +41,7 @@ fn real_main() -> i32 {
APP_ROOT,
&immutable_store,
&translations_manager,
get_path_prefix_server(),
);
if let Err(err) = block_on(export_fut) {
eprintln!("Static exporting failed: '{}'.", err);
Expand Down
3 changes: 1 addition & 2 deletions examples/basic/.perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ pub fn run() -> Result<(), JsValue> {
// We give the app shell a translations manager and let it get the `Rc<Translator>` itself (because it can do async safely)
Rc::clone(&translations_manager),
Rc::clone(&error_pages),
initial_container.unwrap().clone(),
container_rx_elem.clone()
(initial_container.unwrap().clone(), container_rx_elem.clone())
).await,
// If the user is using i18n, then they'll want to detect the locale on any paths missing a locale
// Those all go to the same system that redirects to the appropriate locale
Expand Down
2 changes: 1 addition & 1 deletion examples/basic/src/templates/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub struct IndexPageProps {
pub fn index_page(props: IndexPageProps) -> SycamoreTemplate<G> {
template! {
p {(props.greeting)}
a(href = "/about", id = "about-link") { "About!" }
a(href = "about", id = "about-link") { "About!" }
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/perseus-actix-web/src/configurer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use actix_web::{web, HttpRequest};
use perseus::{
get_render_cfg,
html_shell::prep_html_shell,
path_prefix::get_path_prefix_server,
stores::{ImmutableStore, MutableStore},
ErrorPages, Locales, SsrNode, TemplateMap, TranslationsManager,
};
Expand Down Expand Up @@ -72,7 +73,7 @@ pub async fn configurer<M: MutableStore + 'static, T: TranslationsManager + 'sta
// Get the index file and inject the render configuration into ahead of time
// Anything done here will affect any status code and all loads
let index_file = fs::read_to_string(&opts.index).expect("Couldn't get HTML index file!");
let index_with_render_cfg = prep_html_shell(index_file, &render_cfg);
let index_with_render_cfg = prep_html_shell(index_file, &render_cfg, get_path_prefix_server());

move |cfg: &mut web::ServiceConfig| {
cfg
Expand Down
10 changes: 7 additions & 3 deletions packages/perseus/src/client_translations_manager.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::errors::*;
use crate::path_prefix::get_path_prefix_client;
use crate::shell::fetch;
use crate::Locales;
use crate::Translator;
Expand Down Expand Up @@ -27,6 +28,7 @@ impl ClientTranslationsManager {
&mut self,
locale: &str,
) -> Result<Rc<Translator>, ClientError> {
let path_prefix = get_path_prefix_client();
// Check if we've already cached
if self.cached_translator.is_some()
&& self.cached_translator.as_ref().unwrap().get_locale() == locale
Expand All @@ -36,14 +38,15 @@ impl ClientTranslationsManager {
// Check if the locale is supported and we're actually using i18n
if self.locales.is_supported(locale) && self.locales.using_i18n {
// Get the translations data
let asset_url = format!("/.perseus/translations/{}", locale);
let asset_url = format!("{}/.perseus/translations/{}", path_prefix, locale);
// If this doesn't exist, then it's a 404 (we went here by explicit navigation after checking the locale, so that's a bug)
let translations_str = fetch(&asset_url).await;
let translator = match translations_str {
Ok(translations_str) => match translations_str {
Some(translations_str) => {
// All good, turn the translations into a translator
let translator = Translator::new(locale.to_string(), translations_str);
let translator =
Translator::new(locale.to_string(), translations_str, &path_prefix);
match translator {
Ok(translator) => translator,
Err(err) => {
Expand Down Expand Up @@ -75,7 +78,8 @@ impl ClientTranslationsManager {
Ok(Rc::clone(self.cached_translator.as_ref().unwrap()))
} else if !self.locales.using_i18n {
// If we aren't even using i18n, then it would be pointless to fetch translations
let translator = Translator::new("xx-XX".to_string(), "".to_string()).unwrap();
let translator =
Translator::new("xx-XX".to_string(), "".to_string(), &path_prefix).unwrap();
// Cache that translator
self.cached_translator = Some(Rc::new(translator));
// Now return that
Expand Down
3 changes: 2 additions & 1 deletion packages/perseus/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub async fn export_app(
root_id: &str,
immutable_store: &ImmutableStore,
translations_manager: &impl TranslationsManager,
path_prefix: String,
) -> Result<(), ServerError> {
// The render configuration acts as a guide here, it tells us exactly what we need to iterate over (no request-side pages!)
let render_cfg = get_render_cfg(immutable_store).await?;
Expand All @@ -57,7 +58,7 @@ pub async fn export_app(
path: html_shell_path.to_string(),
source: err,
})?;
let html_shell = prep_html_shell(raw_html_shell, &render_cfg);
let html_shell = prep_html_shell(raw_html_shell, &render_cfg, path_prefix);

// Loop over every partial
for (path, template_path) in render_cfg {
Expand Down
29 changes: 20 additions & 9 deletions packages/perseus/src/html_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,43 @@ use std::collections::HashMap;
use std::env;

/// Initializes the HTML shell by interpolating necessary scripts into it, as well as by adding the render configuration.
pub fn prep_html_shell(html_shell: String, render_cfg: &HashMap<String, String>) -> String {
pub fn prep_html_shell(
html_shell: String,
render_cfg: &HashMap<String, String>,
path_prefix: String,
) -> String {
// Define the script that will load the Wasm bundle (inlined to avoid unnecessary extra requests)
let load_script = r#"<script type="module">
import init, { run } from "/.perseus/bundle.js";
async function main() {
await init("/.perseus/bundle.wasm");
let load_script = format!(
r#"<script type="module">
import init, {{ run }} from "{path_prefix}/.perseus/bundle.js";
async function main() {{
await init("{path_prefix}/.perseus/bundle.wasm");
run();
}
}}
main();
</script>"#;
</script>"#,
path_prefix = path_prefix
);
// We inject a script that defines the render config as a global variable, which we put just before the close of the head
// We also inject a delimiter comment that will be used to wall off the constant document head from the interpolated document head
// We also inject the above script to load the Wasm bundle (avoids extra trips)
// We also inject a global variable to identify that we're testing if we are (picked up by app shell to trigger helper DOM events)
// We also inject a `<base>` tag with the base path of the app, which allows us to serve at relative paths just from an environment variable
let prepared = html_shell.replace(
"</head>",
// It's safe to assume that something we just deserialized will serialize again in this case
&format!(
"<script>window.__PERSEUS_RENDER_CFG = '{}';{testing_var}</script>\n{}\n<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->\n</head>",
"<base href=\"{path_prefix}\" />\n<script>window.__PERSEUS_RENDER_CFG = '{}';{testing_var}</script>\n{}\n<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->\n</head>",
serde_json::to_string(&render_cfg).unwrap(),
load_script,
testing_var=if env::var("PERSEUS_TESTING").is_ok() {
"window.__PERSEUS_TESTING = true;"
} else {
""
}
},
// We add a trailing `/` to the base URL (https://stackoverflow.com/a/26043021)
// Note that it's already had any pre-existing ones stripped away
path_prefix=format!("{}/", path_prefix)
),
);

Expand Down
4 changes: 3 additions & 1 deletion packages/perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ mod locale_detector;
mod locales;
mod log;
mod macros;
/// Utilities relating to working with path prefixes for when a site is hosted at a relative path.
pub mod path_prefix;
/// Utilities regarding routing.
pub mod router;
/// Utilities for serving your app. These are platform-agnostic, and you probably want an integration like [perseus-actix-web](https://crates.io/crates/perseus-actix-web).
Expand All @@ -72,7 +74,7 @@ pub use http::Request as HttpRequest;
/// All HTTP requests use empty bodies for simplicity of passing them around. They'll never need payloads (value in path requested).
pub type Request = HttpRequest<()>;
pub use sycamore::{generic_node::GenericNode, DomNode, SsrNode};
pub use sycamore_router::Route;
pub use sycamore_router::{navigate, Route};

pub use crate::build::{build_app, build_template, build_templates_for_locale};
pub use crate::client_translations_manager::ClientTranslationsManager;
Expand Down
1 change: 1 addition & 0 deletions packages/perseus/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ macro_rules! define_get_translations_manager {
translations_dir.to_string(),
all_locales,
$crate::TRANSLATOR_FILE_EXT.to_string(),
$crate::path_prefix::get_path_prefix_server(),
)
.await
}
Expand Down
38 changes: 38 additions & 0 deletions packages/perseus/src/path_prefix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use std::env;
use wasm_bindgen::JsCast;
use web_sys::{HtmlBaseElement, Url};

/// Gets the path prefix to apply on the server. This uses the `PERSEUS_BASE_PATH` environment variable, which avoids hardcoding
/// something as changeable as this into the final binary. Hence however, that variable must be the same as wht's set in `<base>`.
/// Trailing forward slashes will be trimmed automatically.
pub fn get_path_prefix_server() -> String {
let base_path = env::var("PERSEUS_BASE_PATH").unwrap_or_else(|_| "".to_string());
base_path
.strip_suffix('/')
.unwrap_or(&base_path)
.to_string()
}

/// Gets the path prefix to apply in the browser. This uses the HTML `<base>` element, which would be required anyway to make Sycamore's
/// router co-operate with a relative path hosting.
pub fn get_path_prefix_client() -> String {
let base_path = match web_sys::window()
.unwrap()
.document()
.unwrap()
.query_selector("base[href]")
{
Ok(Some(base)) => {
let base = base.unchecked_into::<HtmlBaseElement>().href();

let url = Url::new(&base).unwrap();
url.pathname()
}
_ => "".to_string(),
};
// Strip any trailing slashes, a `//` makes the browser query `https://.perseus/...`
base_path
.strip_suffix('/')
.unwrap_or(&base_path)
.to_string()
}
7 changes: 4 additions & 3 deletions packages/perseus/src/shell.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::error_pages::ErrorPageData;
use crate::errors::*;
use crate::path_prefix::get_path_prefix_client;
use crate::serve::PageData;
use crate::template::Template;
use crate::ClientTranslationsManager;
Expand Down Expand Up @@ -218,8 +219,7 @@ pub async fn app_shell(
locale: String,
translations_manager: Rc<RefCell<ClientTranslationsManager>>,
error_pages: Rc<ErrorPages<DomNode>>,
initial_container: Element, // The container that the server put initial load content into
container_rx_elem: Element, // The container that we'll actually use (reactive)
(initial_container, container_rx_elem): (Element, Element), // The container that the server put initial load content into and the reactive container tht we'll actually use
) {
checkpoint("app_shell_entry");
// Check if this was an initial load and we already have the state
Expand Down Expand Up @@ -288,7 +288,8 @@ pub async fn app_shell(
};
// Get the static page data
let asset_url = format!(
"/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}",
"{}/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}",
get_path_prefix_client(),
locale,
path.to_string(),
template.get_path(),
Expand Down
20 changes: 14 additions & 6 deletions packages/perseus/src/translations_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,28 @@ pub struct FsTranslationsManager {
cached_locales: Vec<String>,
/// The file extension expected (e.g. JSON, FTL, etc). This allows for greater flexibility of translation engines (future).
file_ext: String,
/// The path prefix for the app.
path_prefix: String,
}
impl FsTranslationsManager {
/// Creates a new filesystem translations manager. You should provide a path like `/translations` here. You should also provide
/// the locales you want to cache, which will have their translations stored in memory. Any supported locales not specified here
/// will not be cached, and must have their translations read from disk on every request. If fetching translations for any of the
/// given locales fails, this will panic (locales to be cached should always be hardcoded).
// TODO performance analysis of manual caching strategy
pub async fn new(root_path: String, locales_to_cache: Vec<String>, file_ext: String) -> Self {
pub async fn new(
root_path: String,
locales_to_cache: Vec<String>,
file_ext: String,
path_prefix: String,
) -> Self {
// Initialize a new instance without any caching first
let mut manager = Self {
root_path,
cached_translations: HashMap::new(),
cached_locales: Vec::new(),
file_ext,
path_prefix,
};
// Now use that to get the translations for the locales we want to cache (all done in parallel)
let mut futs = Vec::new();
Expand Down Expand Up @@ -150,19 +158,19 @@ impl TranslationsManager for FsTranslationsManager {
translations_str = self.get_translations_str_for_locale(locale.clone()).await?;
}
// We expect the translations defined there, but not the locale itself
let translator = Translator::new(locale.clone(), translations_str).map_err(|err| {
TranslationsManagerError::SerializationFailed {
let translator = Translator::new(locale.clone(), translations_str, &self.path_prefix)
.map_err(|err| TranslationsManagerError::SerializationFailed {
locale: locale.clone(),
source: err.into(),
}
})?;
})?;

Ok(translator)
}
}

/// A dummy translations manager for use if you don't want i18n. This avoids errors of not being able to find translations. If you set
/// `no_i18n: true` in the `locales` section of `define_app!`, this will be used by default. If you intend to use i18n, do not use this!
/// Using the `link!` macro with this will NOT prepend the path prefix, and it will result in a nonsensical URL that won't work.
#[derive(Clone, Default)]
pub struct DummyTranslationsManager;
impl DummyTranslationsManager {
Expand All @@ -183,7 +191,7 @@ impl TranslationsManager for DummyTranslationsManager {
&self,
locale: String,
) -> Result<Translator, TranslationsManagerError> {
let translator = Translator::new(locale.clone(), String::new()).map_err(|err| {
let translator = Translator::new(locale.clone(), String::new(), "").map_err(|err| {
TranslationsManagerError::SerializationFailed {
locale,
source: err.into(),
Expand Down
13 changes: 10 additions & 3 deletions packages/perseus/src/translator/fluent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ pub struct FluentTranslator {
bundle: Rc<FluentBundle<FluentResource>>,
/// The locale for which translations are being managed by this instance.
locale: String,
/// The path prefix to apply when calling the `.url()` method.
path_prefix: String,
}
impl FluentTranslator {
/// Creates a new translator for a given locale, passing in translations in FTL syntax form.
pub fn new(locale: String, ftl_string: String) -> Result<Self, TranslatorError> {
pub fn new(
locale: String,
ftl_string: String,
path_prefix: &str,
) -> Result<Self, TranslatorError> {
let resource = FluentResource::try_new(ftl_string)
// If this errors, we get it still and a vector of errors (wtf.)
.map_err(|(_, errs)| TranslatorError::TranslationsStrSerFailed {
Expand Down Expand Up @@ -53,11 +59,12 @@ impl FluentTranslator {
Ok(Self {
bundle: Rc::new(bundle),
locale,
path_prefix: path_prefix.to_string(),
})
}
/// Gets the path to the given URL in whatever locale the instance is configured for.
/// Gets the path to the given URL in whatever locale the instance is configured for. This also applies the path prefix.
pub fn url<S: Into<String> + std::fmt::Display>(&self, url: S) -> String {
format!("/{}{}", self.locale, url)
format!("{}/{}{}", self.path_prefix, self.locale, url)
}
/// Gets the locale for which this instancce is configured.
pub fn get_locale(&self) -> String {
Expand Down
3 changes: 2 additions & 1 deletion website/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ define_app! {
locales: {
default: "en-US",
other: []
}
}// ,
// path_prefix: "/perseus"
}
8 changes: 4 additions & 4 deletions website/src/templates/docs/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub fn docs_page(props: DocsPageProps) -> SycamoreTemplate<G> {
})
// Because of how Perseus currently shifts everything, we need to re-highlight
// And if the user starts on a page with nothing, they'll see no highlighting on any other pages, so we rerun every time the URL changes
script(src = "/.perseus/static/prism.js", defer = true)
script(src = "/perseus/.perseus/static/prism.js", defer = true)
script {
"window.Prism.highlightAll();"
}
Expand All @@ -63,9 +63,9 @@ pub fn get_template<G: GenericNode>() -> Template<G> {
let props: DocsPageProps = serde_json::from_str(&props.unwrap()).unwrap();
template! {
title { (format!("{} | {}", props.title, t!("docs-title-base"))) }
link(rel = "stylesheet", href = "/.perseus/static/styles/markdown.css")
link(rel = "stylesheet", href = "/.perseus/static/styles/docs_links_markdown.css")
link(rel = "stylesheet", href = "/.perseus/static/prism.css")
link(rel = "stylesheet", href = "/perseus/.perseus/static/styles/markdown.css")
link(rel = "stylesheet", href = "/perseus/.perseus/static/styles/docs_links_markdown.css")
link(rel = "stylesheet", href = "/perseus/.perseus/static/prism.css")
}
}))
}
Loading

0 comments on commit b7d6eb6

Please sign in to comment.