mirror of
https://github.com/leptos-rs/leptos.git
synced 2026-07-22 01:57:09 -04:00
613 lines
19 KiB
Rust
613 lines
19 KiB
Rust
#![deny(missing_docs)]
|
||
#![forbid(unsafe_code)]
|
||
|
||
//! # Leptos Meta
|
||
//!
|
||
//! Leptos Meta allows you to modify content in a document’s `<head>` from within components
|
||
//! using the [`Leptos`](https://github.com/leptos-rs/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, [`ServerMetaContextOutput::inject_meta_context`] will inject meta tags into a stream of HTML inside the `<head>`.
|
||
//!
|
||
//! ```
|
||
//! use leptos::prelude::*;
|
||
//! use leptos_meta::*;
|
||
//!
|
||
//! #[component]
|
||
//! fn MyApp() -> impl IntoView {
|
||
//! // Provides a [`MetaContext`], if there is not already one provided.
|
||
//! provide_meta_context();
|
||
//!
|
||
//! let (name, set_name) = create_signal("Alice".to_string());
|
||
//!
|
||
//! view! {
|
||
//! <Title
|
||
//! // reactively sets document.title when `name` changes
|
||
//! text=move || name.get()
|
||
//! // applies the `formatter` function to the `text` value
|
||
//! formatter=|text| format!("“{text}” is your name")
|
||
//! />
|
||
//! <main>
|
||
//! <input
|
||
//! prop:value=move || name.get()
|
||
//! on:input=move |ev| set_name.set(event_target_value(&ev))
|
||
//! />
|
||
//! </main>
|
||
//! }
|
||
//! }
|
||
//! ```
|
||
//! # Feature Flags
|
||
//! - `ssr` Server-side rendering: Generate an HTML string (typically on the server)
|
||
//! - `tracing` Adds integration with the `tracing` crate.
|
||
//!
|
||
//! **Important Note:** If you’re using server-side rendering, you should enable `ssr`.
|
||
|
||
use futures::{Stream, StreamExt};
|
||
use leptos::{
|
||
attr::{any_attribute::AnyAttribute, NextAttribute},
|
||
component,
|
||
logging::debug_warn,
|
||
oco::Oco,
|
||
reactive::owner::{provide_context, use_context},
|
||
tachys::{
|
||
dom::document,
|
||
html::{
|
||
attribute::Attribute,
|
||
element::{ElementType, HtmlElement},
|
||
},
|
||
hydration::Cursor,
|
||
view::{
|
||
add_attr::AddAnyAttr, Mountable, Position, PositionState, Render,
|
||
RenderHtml,
|
||
},
|
||
},
|
||
IntoView,
|
||
};
|
||
use send_wrapper::SendWrapper;
|
||
use std::{
|
||
fmt::Debug,
|
||
sync::{
|
||
mpsc::{channel, Receiver, Sender},
|
||
Arc, LazyLock,
|
||
},
|
||
};
|
||
use wasm_bindgen::JsCast;
|
||
use web_sys::HtmlHeadElement;
|
||
|
||
mod body;
|
||
mod html;
|
||
mod link;
|
||
mod meta_tags;
|
||
mod script;
|
||
mod style;
|
||
mod stylesheet;
|
||
mod title;
|
||
pub use body::*;
|
||
pub use html::*;
|
||
pub use link::*;
|
||
pub use meta_tags::*;
|
||
pub use script::*;
|
||
pub use style::*;
|
||
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_meta_context`].
|
||
#[derive(Clone, Debug)]
|
||
pub struct MetaContext {
|
||
/// Metadata associated with the `<title>` element.
|
||
pub(crate) title: TitleContext,
|
||
/// The hydration cursor for the location in the `<head>` for arbitrary tags will be rendered.
|
||
pub(crate) cursor: Arc<LazyLock<SendWrapper<Cursor>>>,
|
||
}
|
||
|
||
impl MetaContext {
|
||
/// Creates an empty [`MetaContext`].
|
||
pub fn new() -> Self {
|
||
Default::default()
|
||
}
|
||
}
|
||
|
||
pub(crate) const HEAD_MARKER_COMMENT: &str = "HEAD";
|
||
/// Return value of [`Node::node_type`] for a comment.
|
||
/// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#node.comment_node
|
||
const COMMENT_NODE: u16 = 8;
|
||
|
||
impl Default for MetaContext {
|
||
fn default() -> Self {
|
||
let build_cursor: fn() -> SendWrapper<Cursor> = || {
|
||
let head = document().head().expect("missing <head> element");
|
||
let mut cursor = None;
|
||
let mut child = head.first_child();
|
||
while let Some(this_child) = child {
|
||
if this_child.node_type() == COMMENT_NODE
|
||
&& this_child.text_content().as_deref()
|
||
== Some(HEAD_MARKER_COMMENT)
|
||
{
|
||
cursor = Some(this_child);
|
||
break;
|
||
}
|
||
child = this_child.next_sibling();
|
||
}
|
||
SendWrapper::new(Cursor::new(
|
||
cursor
|
||
.expect(
|
||
"no leptos_meta HEAD marker comment found. Did you \
|
||
include the <MetaTags/> component in the <head> of \
|
||
your server-rendered app?",
|
||
)
|
||
.unchecked_into(),
|
||
))
|
||
};
|
||
|
||
let cursor = Arc::new(LazyLock::new(build_cursor));
|
||
Self {
|
||
title: Default::default(),
|
||
cursor,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Allows you to add `<head>` content from components located in the `<body>` of the application,
|
||
/// which can be accessed during server rendering via [`ServerMetaContextOutput`].
|
||
///
|
||
/// This should be provided as context during server rendering.
|
||
///
|
||
/// No content added after the first chunk of the stream has been sent will be included in the
|
||
/// initial `<head>`. Data that needs to be included in the `<head>` during SSR should be
|
||
/// synchronous or loaded as a blocking resource.
|
||
#[derive(Clone, Debug)]
|
||
pub struct ServerMetaContext {
|
||
/// Metadata associated with the `<title>` element.
|
||
pub(crate) title: TitleContext,
|
||
/// Attributes for the `<html>` element.
|
||
pub(crate) html: Sender<String>,
|
||
/// Attributes for the `<body>` element.
|
||
pub(crate) body: Sender<String>,
|
||
/// Arbitrary elements to be added to the `<head>` as HTML.
|
||
#[allow(unused)] // used in SSR
|
||
pub(crate) elements: Sender<String>,
|
||
}
|
||
|
||
/// Allows you to access `<head>` content that was inserted via [`ServerMetaContext`].
|
||
#[must_use = "If you do not use the output, adding meta tags will have no \
|
||
effect."]
|
||
#[derive(Debug)]
|
||
pub struct ServerMetaContextOutput {
|
||
pub(crate) title: TitleContext,
|
||
html: Receiver<String>,
|
||
body: Receiver<String>,
|
||
elements: Receiver<String>,
|
||
}
|
||
|
||
impl ServerMetaContext {
|
||
/// Creates an empty [`ServerMetaContext`].
|
||
pub fn new() -> (ServerMetaContext, ServerMetaContextOutput) {
|
||
let title = TitleContext::default();
|
||
let (html_tx, html_rx) = channel();
|
||
let (body_tx, body_rx) = channel();
|
||
let (elements_tx, elements_rx) = channel();
|
||
let tx = ServerMetaContext {
|
||
title: title.clone(),
|
||
html: html_tx,
|
||
body: body_tx,
|
||
elements: elements_tx,
|
||
};
|
||
let rx = ServerMetaContextOutput {
|
||
title,
|
||
html: html_rx,
|
||
body: body_rx,
|
||
elements: elements_rx,
|
||
};
|
||
(tx, rx)
|
||
}
|
||
}
|
||
|
||
impl ServerMetaContextOutput {
|
||
/// Consumes the metadata, injecting it into the the first chunk of an HTML stream in the
|
||
/// appropriate place.
|
||
///
|
||
/// This means that only meta tags rendered during the first chunk of the stream will be
|
||
/// included.
|
||
pub async fn inject_meta_context(
|
||
self,
|
||
mut stream: impl Stream<Item = String> + Send + Unpin,
|
||
) -> impl Stream<Item = String> + Send {
|
||
// if the first chunk consists of a synchronously-available Suspend,
|
||
// inject_meta_context can accidentally run a tick before it, but the Suspend
|
||
// when both are available. waiting a tick before awaiting the first chunk
|
||
// in the Stream ensures that this always runs after that first chunk
|
||
// see https://github.com/leptos-rs/leptos/issues/3976 for the original issue
|
||
leptos::task::tick().await;
|
||
|
||
// wait for the first chunk of the stream, to ensure our components hve run
|
||
let mut first_chunk = stream.next().await.unwrap_or_default();
|
||
|
||
// create <title> tag
|
||
let title = self.title.as_string();
|
||
let title_len = title
|
||
.as_ref()
|
||
.map(|n| "<title>".len() + n.len() + "</title>".len())
|
||
.unwrap_or(0);
|
||
|
||
// collect all registered meta tags
|
||
let meta_buf = self.elements.try_iter().collect::<String>();
|
||
|
||
// get HTML strings for `<html>` and `<body>`
|
||
let html_attrs = self.html.try_iter().collect::<String>();
|
||
let body_attrs = self.body.try_iter().collect::<String>();
|
||
|
||
let mut modified_chunk = if title_len == 0 && meta_buf.is_empty() {
|
||
first_chunk
|
||
} else {
|
||
let mut buf = String::with_capacity(
|
||
first_chunk.len() + title_len + meta_buf.len(),
|
||
);
|
||
let head_loc = first_chunk
|
||
.find("</head>")
|
||
.expect("you are using leptos_meta without a </head> tag");
|
||
let marker_loc = first_chunk
|
||
.find("<!--HEAD-->")
|
||
.map(|pos| pos + "<!--HEAD-->".len())
|
||
.unwrap_or_else(|| {
|
||
first_chunk.find("</head>").unwrap_or(head_loc)
|
||
});
|
||
let (before_marker, after_marker) =
|
||
first_chunk.split_at_mut(marker_loc);
|
||
buf.push_str(before_marker);
|
||
buf.push_str(&meta_buf);
|
||
if let Some(title) = title {
|
||
buf.push_str("<title>");
|
||
buf.push_str(&title);
|
||
buf.push_str("</title>");
|
||
}
|
||
buf.push_str(after_marker);
|
||
buf
|
||
};
|
||
|
||
if !html_attrs.is_empty() {
|
||
if let Some(index) = modified_chunk.find("<html") {
|
||
// Calculate the position where the new string should be inserted
|
||
let insert_pos = index + "<html".len();
|
||
modified_chunk.insert_str(insert_pos, &html_attrs);
|
||
}
|
||
}
|
||
|
||
if !body_attrs.is_empty() {
|
||
if let Some(index) = modified_chunk.find("<body") {
|
||
// Calculate the position where the new string should be inserted
|
||
let insert_pos = index + "<body".len();
|
||
modified_chunk.insert_str(insert_pos, &body_attrs);
|
||
}
|
||
}
|
||
|
||
futures::stream::once(async move { modified_chunk }).chain(stream)
|
||
}
|
||
}
|
||
|
||
/// Provides a [`MetaContext`], if there is not already one provided. This ensures that you can provide it
|
||
/// at the highest possible level, without overwriting a [`MetaContext`] that has already been provided
|
||
/// (for example, by a server-rendering integration.)
|
||
pub fn provide_meta_context() {
|
||
if use_context::<MetaContext>().is_none() {
|
||
provide_context(MetaContext::new());
|
||
}
|
||
}
|
||
|
||
/// Returns the current [`MetaContext`].
|
||
///
|
||
/// If there is no [`MetaContext`] in this 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 call [`provide_meta_context`] early in the application.
|
||
pub fn use_head() -> MetaContext {
|
||
match use_context::<MetaContext>() {
|
||
None => {
|
||
debug_warn!(
|
||
"use_head() is being called without a MetaContext being \
|
||
provided. We'll automatically create and provide one, but if \
|
||
this is being called in a child route it may cause bugs. To \
|
||
be safe, you should provide_meta_context() somewhere in the \
|
||
root of the app."
|
||
);
|
||
let meta = MetaContext::new();
|
||
provide_context(meta.clone());
|
||
meta
|
||
}
|
||
Some(ctx) => ctx,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn register<E, At, Ch>(
|
||
el: HtmlElement<E, At, Ch>,
|
||
) -> RegisteredMetaTag<E, At, Ch>
|
||
where
|
||
HtmlElement<E, At, Ch>: RenderHtml,
|
||
{
|
||
RegisteredMetaTag { el }
|
||
}
|
||
|
||
struct RegisteredMetaTag<E, At, Ch> {
|
||
// this is `None` if we've already taken it out to render to HTML on the server
|
||
// we don't render it in place in RenderHtml, so it's fine
|
||
el: HtmlElement<E, At, Ch>,
|
||
}
|
||
|
||
struct RegisteredMetaTagState<E, At, Ch>
|
||
where
|
||
HtmlElement<E, At, Ch>: Render,
|
||
{
|
||
state: <HtmlElement<E, At, Ch> as Render>::State,
|
||
}
|
||
|
||
impl<E, At, Ch> Drop for RegisteredMetaTagState<E, At, Ch>
|
||
where
|
||
HtmlElement<E, At, Ch>: Render,
|
||
{
|
||
fn drop(&mut self) {
|
||
self.state.unmount();
|
||
}
|
||
}
|
||
|
||
fn document_head() -> HtmlHeadElement {
|
||
let document = document();
|
||
document.head().unwrap_or_else(|| {
|
||
let el = document.create_element("head").unwrap();
|
||
let document = document.document_element().unwrap();
|
||
_ = document.append_child(&el);
|
||
el.unchecked_into()
|
||
})
|
||
}
|
||
|
||
impl<E, At, Ch> Render for RegisteredMetaTag<E, At, Ch>
|
||
where
|
||
E: ElementType,
|
||
At: Attribute,
|
||
Ch: Render,
|
||
{
|
||
type State = RegisteredMetaTagState<E, At, Ch>;
|
||
|
||
fn build(self) -> Self::State {
|
||
let state = self.el.build();
|
||
RegisteredMetaTagState { state }
|
||
}
|
||
|
||
fn rebuild(self, state: &mut Self::State) {
|
||
self.el.rebuild(&mut state.state);
|
||
}
|
||
}
|
||
|
||
impl<E, At, Ch> AddAnyAttr for RegisteredMetaTag<E, At, Ch>
|
||
where
|
||
E: ElementType + Send,
|
||
At: Attribute + Send,
|
||
Ch: RenderHtml + Send,
|
||
{
|
||
type Output<SomeNewAttr: Attribute> =
|
||
RegisteredMetaTag<E, <At as NextAttribute>::Output<SomeNewAttr>, Ch>;
|
||
|
||
fn add_any_attr<NewAttr: Attribute>(
|
||
self,
|
||
attr: NewAttr,
|
||
) -> Self::Output<NewAttr>
|
||
where
|
||
Self::Output<NewAttr>: RenderHtml,
|
||
{
|
||
RegisteredMetaTag {
|
||
el: self.el.add_any_attr(attr),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<E, At, Ch> RenderHtml for RegisteredMetaTag<E, At, Ch>
|
||
where
|
||
E: ElementType,
|
||
At: Attribute,
|
||
Ch: RenderHtml + Send,
|
||
{
|
||
type AsyncOutput = Self;
|
||
type Owned = RegisteredMetaTag<E, At::CloneableOwned, Ch::Owned>;
|
||
|
||
const MIN_LENGTH: usize = 0;
|
||
const EXISTS: bool = false;
|
||
|
||
fn dry_resolve(&mut self) {
|
||
self.el.dry_resolve()
|
||
}
|
||
|
||
async fn resolve(self) -> Self::AsyncOutput {
|
||
self // TODO?
|
||
}
|
||
|
||
fn to_html_with_buf(
|
||
self,
|
||
_buf: &mut String,
|
||
_position: &mut Position,
|
||
_escape: bool,
|
||
_mark_branches: bool,
|
||
_extra_attrs: Vec<AnyAttribute>,
|
||
) {
|
||
// meta tags are rendered into the buffer stored into the context
|
||
// the value has already been taken out, when we're on the server
|
||
#[cfg(feature = "ssr")]
|
||
if let Some(cx) = use_context::<ServerMetaContext>() {
|
||
let mut buf = String::new();
|
||
self.el.to_html_with_buf(
|
||
&mut buf,
|
||
&mut Position::NextChild,
|
||
false,
|
||
false,
|
||
vec![],
|
||
);
|
||
_ = cx.elements.send(buf); // fails only if the receiver is already dropped
|
||
} else {
|
||
let msg = "tried to use a leptos_meta component without \
|
||
`ServerMetaContext` provided";
|
||
|
||
#[cfg(feature = "tracing")]
|
||
tracing::warn!("{}", msg);
|
||
|
||
#[cfg(not(feature = "tracing"))]
|
||
eprintln!("{msg}");
|
||
}
|
||
}
|
||
|
||
fn hydrate<const FROM_SERVER: bool>(
|
||
self,
|
||
_cursor: &Cursor,
|
||
_position: &PositionState,
|
||
) -> Self::State {
|
||
let cursor = use_context::<MetaContext>()
|
||
.expect(
|
||
"attempting to hydrate `leptos_meta` components without a \
|
||
MetaContext provided",
|
||
)
|
||
.cursor;
|
||
let state = self.el.hydrate::<FROM_SERVER>(
|
||
&cursor,
|
||
&PositionState::new(Position::NextChild),
|
||
);
|
||
RegisteredMetaTagState { state }
|
||
}
|
||
|
||
fn into_owned(self) -> Self::Owned {
|
||
RegisteredMetaTag {
|
||
el: self.el.into_owned(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<E, At, Ch> Mountable for RegisteredMetaTagState<E, At, Ch>
|
||
where
|
||
E: ElementType,
|
||
At: Attribute,
|
||
Ch: Render,
|
||
{
|
||
fn unmount(&mut self) {
|
||
self.state.unmount();
|
||
}
|
||
|
||
fn mount(
|
||
&mut self,
|
||
_parent: &leptos::tachys::renderer::types::Element,
|
||
_marker: Option<&leptos::tachys::renderer::types::Node>,
|
||
) {
|
||
// we always mount this to the <head>, which is the whole point
|
||
// but this shouldn't warn about the parent being a regular element or being unused
|
||
// because it will call "mount" with the parent where it is located in the component tree,
|
||
// but actually be mounted to the <head>
|
||
self.state.mount(&document_head(), None);
|
||
}
|
||
|
||
fn insert_before_this(&self, _child: &mut dyn Mountable) -> bool {
|
||
// Registered meta tags will be mounted in the <head>, but *seem* to be mounted somewhere
|
||
// else in the DOM. We should never tell the renderer that we have successfully mounted
|
||
// something before this, because if e.g., a <Meta/> is the first item in an Either, then
|
||
// the alternate view will end up being mounted in the <head> -- which is not at all what
|
||
// we intended!
|
||
false
|
||
}
|
||
|
||
fn elements(&self) -> Vec<leptos::tachys::renderer::types::Element> {
|
||
self.state.elements()
|
||
}
|
||
}
|
||
|
||
/// During server rendering, inserts the meta tags that have been generated by the other components
|
||
/// in this crate into the DOM. This should be placed somewhere inside the `<head>` element that is
|
||
/// being used during server rendering.
|
||
#[component]
|
||
pub fn MetaTags() -> impl IntoView {
|
||
MetaTagsView
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct MetaTagsView;
|
||
|
||
// this implementation doesn't do anything during client-side rendering, it's just for server-side
|
||
// rendering HTML for all the tags that will be injected into the `<head>`
|
||
//
|
||
// client-side rendering is handled by the individual components
|
||
impl Render for MetaTagsView {
|
||
type State = ();
|
||
|
||
fn build(self) -> Self::State {}
|
||
|
||
fn rebuild(self, _state: &mut Self::State) {}
|
||
}
|
||
|
||
impl AddAnyAttr for MetaTagsView {
|
||
type Output<SomeNewAttr: Attribute> = MetaTagsView;
|
||
|
||
fn add_any_attr<NewAttr: Attribute>(
|
||
self,
|
||
_attr: NewAttr,
|
||
) -> Self::Output<NewAttr>
|
||
where
|
||
Self::Output<NewAttr>: RenderHtml,
|
||
{
|
||
self
|
||
}
|
||
}
|
||
|
||
impl RenderHtml for MetaTagsView {
|
||
type AsyncOutput = Self;
|
||
type Owned = Self;
|
||
|
||
const MIN_LENGTH: usize = 0;
|
||
|
||
fn dry_resolve(&mut self) {}
|
||
|
||
async fn resolve(self) -> Self::AsyncOutput {
|
||
self
|
||
}
|
||
|
||
fn to_html_with_buf(
|
||
self,
|
||
buf: &mut String,
|
||
_position: &mut Position,
|
||
_escape: bool,
|
||
_mark_branches: bool,
|
||
_extra_attrs: Vec<AnyAttribute>,
|
||
) {
|
||
buf.push_str("<!--HEAD-->");
|
||
}
|
||
|
||
fn hydrate<const FROM_SERVER: bool>(
|
||
self,
|
||
_cursor: &Cursor,
|
||
_position: &PositionState,
|
||
) -> Self::State {
|
||
}
|
||
|
||
fn into_owned(self) -> Self::Owned {
|
||
self
|
||
}
|
||
}
|
||
|
||
pub(crate) trait OrDefaultNonce {
|
||
fn or_default_nonce(self) -> Option<Oco<'static, str>>;
|
||
}
|
||
|
||
impl OrDefaultNonce for Option<Oco<'static, str>> {
|
||
fn or_default_nonce(self) -> Option<Oco<'static, str>> {
|
||
#[cfg(feature = "nonce")]
|
||
{
|
||
use leptos::nonce::use_nonce;
|
||
|
||
match self {
|
||
Some(nonce) => Some(nonce),
|
||
None => use_nonce().map(|n| Arc::clone(n.as_inner()).into()),
|
||
}
|
||
}
|
||
#[cfg(not(feature = "nonce"))]
|
||
{
|
||
self
|
||
}
|
||
}
|
||
}
|