Fixes #98, cleans up leptos_meta, and improves interface by removing manual .into() calls

This commit is contained in:
Greg Johnston 2022-11-21 10:47:54 -05:00
parent 014b5f9453
commit 89f837d3b6
7 changed files with 250 additions and 88 deletions

View file

@ -17,7 +17,7 @@ pub fn App(cx: Scope) -> Element {
view! {
cx,
<div>
<Stylesheet href="/static/style.css".into()/>
<Stylesheet href="/static/style.css"/>
<Router>
<Nav />
<main>

View file

@ -16,7 +16,7 @@ pub fn App(cx: Scope) -> Element {
view! {
cx,
<div>
<Stylesheet href="/static/style.css".into()/>
<Stylesheet href="/static/style.css"/>
<Router>
<Nav />
<main>

View file

@ -92,7 +92,7 @@ pub fn TodoApp(cx: Scope) -> Element {
view! {
cx,
<div>
<Stylesheet href="/style.css".into()/>
<Stylesheet href="/style.css"/>
<Router>
<header>
<h1>"My Tasks"</h1>

View file

@ -8,18 +8,16 @@ repository = "https://github.com/gbj/leptos"
description = "Tools to set HTML metadata in the Leptos web framework."
[dependencies]
cfg-if = "1"
leptos = { path = "../leptos", version = "0.0", default-features = false }
log = "0.4"
typed-builder = "0.11"
[dependencies.web-sys]
version = "0.3"
features = [
"HtmlLinkElement",
"HtmlTitleElement"
]
features = ["HtmlLinkElement", "HtmlTitleElement"]
[features]
default = ["csr"]
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
ssr = ["leptos/ssr"]
ssr = ["leptos/ssr"]

View file

@ -1,3 +1,41 @@
#![deny(missing_docs)]
//! # Leptos Meta
//!
//! Leptos Meta allows you to modify content in a documents `<head>` from within components
//! using the [Leptos](https://github.com/gbj/leptos) web framework.
//!
//! Document metadata is updated automatically when running in the browser. For server-side
//! rendering, after the component tree is rendered to HTML, [MetaContext::dehydrate] can generate
//! HTML that should be injected into the `<head>` of the HTML document being rendered.
//!
//! ```
//! use leptos::*;
//! use leptos_meta::*;
//!
//! #[component]
//! fn MyApp(cx: Scope) -> Element {
//! let (name, set_name) = create_signal(cx, "Alice".to_string());
//!
//! view! { cx,
//! <main>
//! <Title
//! // reactively sets document.title when `name` changes
//! text=name
//! // applies the `formatter` function to the `text` value
//! formatter=|text| format!("“{text}” is your name")
//! />
//! <input
//! prop:value=name
//! on:input=move |ev| set_name(event_target_value(&ev))
//! />
//! </main>
//! }
//!
//! }
//!
//! ```
use std::fmt::Debug;
use leptos::{leptos_dom::debug_warn, *};
@ -7,12 +45,24 @@ mod title;
pub use stylesheet::*;
pub use title::*;
/// Contains the current state of meta tags. To access it, you can use [use_head].
///
/// This should generally by provided somewhere in the root of your application using
/// `provide_context(cx, MetaContext::new())`.
#[derive(Debug, Clone, Default)]
pub struct MetaContext {
pub(crate) title: TitleContext,
pub(crate) stylesheets: StylesheetContext,
}
/// Returns the current [MetaContext].
///
/// If there is no [MetaContext] in this [Scope](leptos::Scope) or any parent scope, this will
/// create a new [MetaContext] and provide it to the current scope.
///
/// Note that this may cause confusing behavior, e.g., if multiple nested routes independently
/// call `use_head()` but a single [MetaContext] has not been provided at the application root.
/// The best practice is always to `provide_context(cx, MetaContext::new())` early in the application.
pub fn use_head(cx: Scope) -> MetaContext {
match use_context::<MetaContext>(cx) {
None => {
@ -26,11 +76,40 @@ pub fn use_head(cx: Scope) -> MetaContext {
}
impl MetaContext {
/// Creates an empty [MetaContext].
pub fn new() -> Self {
Default::default()
}
#[cfg(feature = "ssr")]
#[cfg(any(feature = "ssr", doc))]
/// Converts the existing metadata tags into HTML that can be injected into the document head.
///
/// This should be called *after* the apps component tree has been rendered into HTML, so that
/// components can set meta tags.
///
/// ```
/// use leptos::*;
/// use leptos_meta::*;
///
/// # #[cfg(not(any(feature = "csr", feature = "hydrate")))] {
/// run_scope(|cx| {
/// provide_context(cx, MetaContext::new());
///
/// let app = view! { cx,
/// <main>
/// <Title text="my title"/>
/// <Stylesheet href="/style.css"/>
/// <p>"Some text"</p>
/// </main>
/// };
///
/// // `app` contains only the body content w/ hydration stuff, not the meta tags
/// assert_eq!(app, r#"<main data-hk="0-0"><!--#--><!--/--><!--#--><!--/--><p>Some text</p></main>"#);
/// // `MetaContext::dehydrate()` gives you HTML that should be in the `<head>`
/// assert_eq!(use_head(cx).dehydrate(), r#"<title>my title</title><link rel="stylesheet" href="/style.css">"#)
/// });
/// # }
/// ```
pub fn dehydrate(&self) -> String {
let mut tags = String::new();
@ -48,6 +127,8 @@ impl MetaContext {
}
}
/// Describes a value that is either a static or a reactive string, i.e.,
/// a [String], a [&str], or a reactive `Fn() -> String`.
pub struct TextProp(Box<dyn Fn() -> String>);
impl Debug for TextProp {

View file

@ -1,13 +1,17 @@
use crate::use_head;
use cfg_if::cfg_if;
use leptos::*;
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use typed_builder::TypedBuilder;
/// Manages all of the stylesheets set by [Stylesheet] components.
#[derive(Clone, Default, Debug)]
pub struct StylesheetContext {
els: Rc<RefCell<HashMap<String, Option<web_sys::HtmlLinkElement>>>>,
}
impl StylesheetContext {
/// Converts the set of stylesheets into an HTML string that can be injected into the `<head>`.
pub fn as_string(&self) -> String {
self.els
.borrow()
@ -17,40 +21,66 @@ impl StylesheetContext {
}
}
#[cfg(feature = "ssr")]
#[component]
pub fn Stylesheet(cx: Scope, href: String) {
let meta = use_head(cx);
meta.stylesheets.els.borrow_mut().insert(href, None);
/// Properties for the [Stylesheet] component.
#[derive(TypedBuilder)]
pub struct StylesheetProps {
/// The URL at which the stylesheet can be located.
#[builder(setter(into))]
href: String,
}
#[cfg(not(feature = "ssr"))]
#[component]
pub fn Stylesheet(cx: Scope, href: String) {
use leptos::document;
/// Injects an [HTMLLinkElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement) into the document
/// head that loads a stylesheet from the URL given by the `href` property.
///
/// ```
/// use leptos::*;
/// use leptos_meta::*;
///
/// #[component]
/// fn MyApp(cx: Scope) -> Element {
/// provide_context(cx, MetaContext::new());
///
/// view! { cx,
/// <main>
/// <Stylesheet href="/style.css"/>
/// </main>
/// }
/// }
/// ```
#[allow(non_snake_case)]
pub fn Stylesheet(cx: Scope, props: StylesheetProps) {
let StylesheetProps { href } = props;
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
use leptos::document;
let meta = use_head(cx);
let meta = use_head(cx);
// TODO I guess this will create a duplicated <link> when hydrating
let existing_el = {
let els = meta.stylesheets.els.borrow();
els.get(&href).cloned()
};
if let Some(Some(_)) = existing_el {
log::warn!("<Stylesheet/> already loaded stylesheet {href}");
} else {
let el = document().create_element("link").unwrap_throw();
el.set_attribute("rel", "stylesheet").unwrap_throw();
el.set_attribute("href", &href).unwrap_throw();
document()
.query_selector("head")
.unwrap_throw()
.unwrap_throw()
.append_child(el.unchecked_ref())
.unwrap_throw();
meta.stylesheets
.els
.borrow_mut()
.insert(href, Some(el.unchecked_into()));
// TODO I guess this will create a duplicated <link> when hydrating
let existing_el = {
let els = meta.stylesheets.els.borrow();
els.get(&href).cloned()
};
if let Some(Some(_)) = existing_el {
leptos::leptos_dom::debug_warn!("<Stylesheet/> already loaded stylesheet {href}");
} else {
let el = document().create_element("link").unwrap_throw();
el.set_attribute("rel", "stylesheet").unwrap_throw();
el.set_attribute("href", &href).unwrap_throw();
document()
.query_selector("head")
.unwrap_throw()
.unwrap_throw()
.append_child(el.unchecked_ref())
.unwrap_throw();
meta.stylesheets
.els
.borrow_mut()
.insert(href, Some(el.unchecked_into()));
}
} else {
let meta = use_head(cx);
meta.stylesheets.els.borrow_mut().insert(href, None);
}
}
}

View file

@ -1,16 +1,20 @@
use crate::{use_head, TextProp};
use cfg_if::cfg_if;
use leptos::*;
use std::{cell::RefCell, rc::Rc};
use typed_builder::TypedBuilder;
/// Contains the current state of the document's `<title>`.
#[derive(Clone, Default)]
pub struct TitleContext {
#[cfg(not(feature = "ssr"))]
#[cfg(any(feature = "csr", feature = "hydrate"))]
el: Rc<RefCell<Option<web_sys::HtmlTitleElement>>>,
formatter: Rc<RefCell<Option<Formatter>>>,
text: Rc<RefCell<Option<TextProp>>>,
}
impl TitleContext {
/// Converts the title into a string that can be used as the text content of a `<title>` tag.
pub fn as_string(&self) -> Option<String> {
let title = self.text.borrow().as_ref().map(|f| (f.0)());
title.map(|title| {
@ -29,6 +33,7 @@ impl std::fmt::Debug for TitleContext {
}
}
/// A function that is applied to the text value before setting `document.title`.
pub struct Formatter(Box<dyn Fn(String) -> String>);
impl<F> From<F> for Formatter
@ -40,57 +45,105 @@ where
}
}
#[cfg(feature = "ssr")]
#[component]
pub fn Title(cx: Scope, formatter: Option<Formatter>, text: Option<TextProp>) {
let meta = use_head(cx);
if let Some(formatter) = formatter {
*meta.title.formatter.borrow_mut() = Some(formatter);
}
if let Some(text) = text {
*meta.title.text.borrow_mut() = Some(text.into());
}
log::debug!("setting title to {:?}", meta.title.as_string());
/// Properties for the [Title] component.
#[derive(TypedBuilder)]
pub struct TitleProps {
/// A function that will be applied to any text value before its set as the title.
#[builder(default, setter(strip_option, into))]
formatter: Option<Formatter>,
// Sets the the current `document.title`.
#[builder(default, setter(strip_option, into))]
text: Option<TextProp>,
}
#[cfg(not(feature = "ssr"))]
#[component]
pub fn Title(cx: Scope, formatter: Option<Formatter>, text: Option<TextProp>) {
use crate::use_head;
/// A component to set the documents title by creating an [HTMLTitleElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLTitleElement).
///
/// The `title` and `formatter` can be set independently of one another. For example, you can create a root-level
/// `<Title formatter=.../>` that will wrap each of the text values of `<Title/>` components created lower in the tree.
///
/// ```
/// use leptos::*;
/// use leptos_meta::*;
///
/// #[component]
/// fn MyApp(cx: Scope) -> Element {
/// provide_context(cx, MetaContext::new());
/// let formatter = |text| format!("{text} — Leptos Online");
///
/// view! { cx,
/// <main>
/// <Title formatter/>
/// // ... routing logic here
/// </main>
/// }
/// }
///
/// #[component]
/// fn PageA(cx: Scope) -> Element {
/// view! { cx,
/// <main>
/// <Title text="Page A"/> // sets title to "Page A — Leptos Online"
/// </main>
/// }
/// }
///
/// #[component]
/// fn PageB(cx: Scope) -> Element {
/// view! { cx,
/// <main>
/// <Title text="Page B"/> // sets title to "Page B — Leptos Online"
/// </main>
/// }
/// }
/// ```
#[allow(non_snake_case)]
pub fn Title(cx: Scope, props: TitleProps) {
let meta = use_head(cx);
if let Some(formatter) = formatter {
*meta.title.formatter.borrow_mut() = Some(formatter);
}
if let Some(text) = text {
*meta.title.text.borrow_mut() = Some(text.into());
}
let TitleProps { text, formatter } = props;
let el = {
let el_ref = meta.title.el.borrow_mut();
let el = if let Some(el) = &*el_ref {
el.clone()
} else {
match document().query_selector("title") {
Ok(Some(title)) => title.unchecked_into(),
_ => {
let el = document().create_element("title").unwrap_throw();
document()
.query_selector("head")
.unwrap_throw()
.unwrap_throw()
.append_child(el.unchecked_ref())
.unwrap_throw();
el.unchecked_into()
}
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
if let Some(formatter) = formatter {
*meta.title.formatter.borrow_mut() = Some(formatter);
}
if let Some(text) = text {
*meta.title.text.borrow_mut() = Some(text);
}
};
el
};
create_render_effect(cx, move |_| {
let text = meta.title.as_string().unwrap_or_default();
let el = {
let el_ref = meta.title.el.borrow_mut();
let el = if let Some(el) = &*el_ref {
el.clone()
} else {
match document().query_selector("title") {
Ok(Some(title)) => title.unchecked_into(),
_ => {
let el = document().create_element("title").unwrap_throw();
document()
.query_selector("head")
.unwrap_throw()
.unwrap_throw()
.append_child(el.unchecked_ref())
.unwrap_throw();
el.unchecked_into()
}
}
};
el
};
el.set_text_content(Some(&text));
});
create_render_effect(cx, move |_| {
let text = meta.title.as_string().unwrap_or_default();
el.set_text_content(Some(&text));
});
} else {
if let Some(formatter) = formatter {
*meta.title.formatter.borrow_mut() = Some(formatter);
}
if let Some(text) = text {
*meta.title.text.borrow_mut() = Some(text);
}
}
}
}