mirror of
https://github.com/leptos-rs/leptos
synced 2024-11-10 14:54:16 +00:00
Fixes #98, cleans up leptos_meta
, and improves interface by removing manual .into() calls
This commit is contained in:
parent
014b5f9453
commit
89f837d3b6
7 changed files with 250 additions and 88 deletions
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -1,3 +1,41 @@
|
|||
#![deny(missing_docs)]
|
||||
|
||||
//! # Leptos Meta
|
||||
//!
|
||||
//! Leptos Meta allows you to modify content in a document’s `<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 app’s 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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 it’s 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 document’s 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue