diff --git a/README.md b/README.md
index cf48ed3d82..9b91c7e86d 100644
--- a/README.md
+++ b/README.md
@@ -32,10 +32,14 @@ These tasks still need to be done before Perseus can be pushed to v1.0.0.
- [x] Create a custom CLI as a harness for apps without ridiculous amounts of configuration needed
-* [ ] Support custom template hierarchies
* [x] Support i18n out of the box
-* [x] (Maybe) Implement custom router
-* [ ] Pre-built integrations for Actix Web (done) and AWS Lambda (todo)
+* [x] Implement custom router
+* [x] Allow direct modification of the document head
+* [ ] Improve SEO and initial load performance
+* [ ] Support custom template hierarchies
+* [ ] Pre-built integrations
+ - [x] Actix Web
+ - [ ] AWS Lambda
### Beyond
diff --git a/examples/basic/index.html b/examples/basic/index.html
index d9b3c52d9d..aadba0f1d1 100644
--- a/examples/basic/index.html
+++ b/examples/basic/index.html
@@ -4,7 +4,6 @@
-
Perseus Starter App
diff --git a/examples/cli/index.html b/examples/cli/index.html
index d9b3c52d9d..aadba0f1d1 100644
--- a/examples/cli/index.html
+++ b/examples/cli/index.html
@@ -4,7 +4,6 @@
- Perseus Starter App
diff --git a/examples/cli/src/pages/about.rs b/examples/cli/src/pages/about.rs
index 8c88a6f470..db91bda52d 100644
--- a/examples/cli/src/pages/about.rs
+++ b/examples/cli/src/pages/about.rs
@@ -10,7 +10,9 @@ pub fn about_page() -> SycamoreTemplate {
}
pub fn get_page() -> Template {
- Template::new("about").template(template_fn())
+ Template::new("about")
+ .template(template_fn())
+ .head(head_fn())
}
pub fn template_fn() -> perseus::template::TemplateFn {
@@ -20,3 +22,11 @@ pub fn template_fn() -> perseus::template::TemplateFn {
}
})
}
+
+pub fn head_fn() -> perseus::template::HeadFn {
+ Rc::new(|_| {
+ template! {
+ title { "About Page | Perseus Example – Basic" }
+ }
+ })
+}
diff --git a/examples/cli/src/pages/index.rs b/examples/cli/src/pages/index.rs
index 30a0423953..8c63f16c97 100644
--- a/examples/cli/src/pages/index.rs
+++ b/examples/cli/src/pages/index.rs
@@ -20,6 +20,7 @@ pub fn get_page() -> Template {
Template::new("index")
.build_state_fn(Rc::new(get_static_props))
.template(template_fn())
+ .head(head_fn())
}
pub async fn get_static_props(_path: String) -> StringResultWithCause {
@@ -38,3 +39,11 @@ pub fn template_fn() -> perseus::template::TemplateFn {
}
})
}
+
+pub fn head_fn() -> perseus::template::HeadFn {
+ Rc::new(|_| {
+ template! {
+ title { "Index Page | Perseus Example – Basic" }
+ }
+ })
+}
diff --git a/examples/i18n/index.html b/examples/i18n/index.html
index d9b3c52d9d..08b218389d 100644
--- a/examples/i18n/index.html
+++ b/examples/i18n/index.html
@@ -4,7 +4,7 @@
- Perseus Starter App
+ Perseus Example – i18n
diff --git a/examples/showcase/index.html b/examples/showcase/index.html
index d7afe211a6..75f8106348 100644
--- a/examples/showcase/index.html
+++ b/examples/showcase/index.html
@@ -4,7 +4,7 @@
- Perseus Showcase App
+ Perseus Example – Showcase
diff --git a/packages/perseus-actix-web/src/configurer.rs b/packages/perseus-actix-web/src/configurer.rs
index 8e68d23a6c..c4a61e0b04 100644
--- a/packages/perseus-actix-web/src/configurer.rs
+++ b/packages/perseus-actix-web/src/configurer.rs
@@ -56,12 +56,13 @@ pub async fn configurer",
// It's safe to assume that something we just deserialized will serialize again in this case
&format!(
- "\n",
+ "\n\n",
serde_json::to_string(&render_cfg).unwrap()
),
);
diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs
index 9b1496dcee..53a3d1c044 100644
--- a/packages/perseus/src/shell.rs
+++ b/packages/perseus/src/shell.rs
@@ -186,6 +186,23 @@ pub fn app_shell(
}
};
+ // Render the document head
+ let head_str = template.render_head_str(page_data.state.clone(), Rc::clone(&translator));
+ // Get the current head
+ let head_elem = web_sys::window()
+ .unwrap()
+ .document()
+ .unwrap()
+ .query_selector("head")
+ .unwrap()
+ .unwrap();
+ let head_html = head_elem.inner_html();
+ // We'll assume that there's already previously interpolated head in addition to the hardcoded stuff, but it will be separated by the server-injected delimiter comment
+ // Thus, we replace the stuff after that delimiter comment with the new head
+ let head_parts: Vec<&str> = head_html.split("").collect();
+ let new_head = format!("{}\n{}", head_parts[0], head_str);
+ head_elem.set_inner_html(&new_head);
+
// Hydrate that static code using the acquired state
// BUG (Sycamore): this will double-render if the component is just text (no nodes)
sycamore::hydrate_to(
diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs
index 858fe64e19..74667eef5c 100644
--- a/packages/perseus/src/template.rs
+++ b/packages/perseus/src/template.rs
@@ -2,6 +2,7 @@
use crate::errors::*;
use crate::Request;
+use crate::SsrNode;
use crate::Translator;
use futures::Future;
use std::collections::HashMap;
@@ -113,6 +114,10 @@ make_async_trait!(ShouldRevalidateFnType, StringResultWithCause);
/// The type of functions that are given a state and render a page. If you've defined state for your page, it's safe to `.unwrap()` the
/// given `Option`. If you're using i18n, an `Rc` will also be made available through Sycamore's [context system](https://sycamore-rs.netlify.app/docs/advanced/advanced_reactivity).
pub type TemplateFn = Rc) -> SycamoreTemplate>;
+/// A type alias for the function that modifies the document head. This is just a template function that will always be server-side
+/// rendered in function (it may be rendered on the client, but it will always be used to create an HTML string, rather than a reactive
+/// template).
+pub type HeadFn = TemplateFn;
/// The type of functions that get build paths.
pub type GetBuildPathsFn = Rc;
/// The type of functions that get build state.
@@ -137,6 +142,10 @@ pub struct Template {
/// This will be executed inside `sycamore::render_to_string`, and should return a `Template`. This takes an `Option`
/// because otherwise efficient typing is almost impossible for templates without any properties (solutions welcome in PRs!).
template: TemplateFn,
+ /// A function that will be used to populate the document's `` with metadata such as the title. This will be passed state in
+ /// the same way as `template`, but will always be rendered to a string, whcih will then be interpolated directly into the ``,
+ /// so reactivity here will not work!
+ head: TemplateFn,
/// A function that gets the paths to render for at built-time. This is equivalent to `get_static_paths` in NextJS. If
/// `incremental_path_rendering` is `true`, more paths can be rendered at request time on top of these.
get_build_paths: Option,
@@ -171,6 +180,8 @@ impl Template {
Self {
path: path.to_string(),
template: Rc::new(|_: Option| sycamore::template! {}),
+ // Unlike `template`, this may not be set at all (especially in very simple apps)
+ head: Rc::new(|_: Option| sycamore::template! {}),
get_build_paths: None,
incremental_path_rendering: false,
get_build_state: None,
@@ -183,7 +194,6 @@ impl Template {
// Render executors
/// Executes the user-given function that renders the template on the server-side (build or request time).
- // TODO possibly duplicate routes context here to avoid disappearance issues?
pub fn render_for_template(
&self,
props: Option,
@@ -197,6 +207,19 @@ impl Template {
})
}
}
+ /// Executes the user-given function that renders the document ``, returning a string to be interpolated manually. Reactivity
+ /// in this function will not take effect due to this string rendering. Note that this function will provide a translator context.
+ pub fn render_head_str(&self, props: Option, translator: Rc) -> String {
+ sycamore::render_to_string(|| {
+ template! {
+ // We provide the translator through context, which avoids having to define a separate variable for every translation due to Sycamore's `template!` macro taking ownership with `move` closures
+ ContextProvider(ContextProviderProps {
+ value: Rc::clone(&translator),
+ children: || (self.head)(props)
+ })
+ }
+ })
+ }
/// Gets the list of templates that should be prerendered for at build-time.
pub async fn get_build_paths(&self) -> Result> {
if let Some(get_build_paths) = &self.get_build_paths {
@@ -363,6 +386,11 @@ impl Template {
self.template = val;
self
}
+ /// Sets the document head rendering function to use.
+ pub fn head(mut self, val: TemplateFn) -> Template {
+ self.head = val;
+ self
+ }
/// Enables the *build paths* strategy with the given function.
pub fn build_paths_fn(mut self, val: GetBuildPathsFn) -> Template {
self.get_build_paths = Some(val);