From 2fefc8b4bf0f173298723f71f7b60017a5928984 Mon Sep 17 00:00:00 2001 From: Greg Johnston Date: Wed, 13 Mar 2024 19:55:23 -0400 Subject: [PATCH] completing work on meta --- meta/Cargo.toml | 5 +- meta/src/html.rs | 19 ++- meta/src/lib.rs | 289 +++++++++++++++++++++++++++++++++++++---- meta/src/link.rs | 79 +++++------ meta/src/meta_tags.rs | 48 +++---- meta/src/script.rs | 83 +++++------- meta/src/style.rs | 68 ++++------ meta/src/stylesheet.rs | 26 ++-- meta/src/title.rs | 88 ++++++++----- 9 files changed, 469 insertions(+), 236 deletions(-) diff --git a/meta/Cargo.toml b/meta/Cargo.toml index 71b1854ab..6ff27ae5e 100644 --- a/meta/Cargo.toml +++ b/meta/Cargo.toml @@ -10,11 +10,12 @@ rust-version.workspace = true [dependencies] leptos = { workspace = true } +once_cell = "1" or_poisoned = { workspace = true } -tracing = "0.1" -wasm-bindgen = "0.2" indexmap = "2" send_wrapper = "0.6.0" +tracing = "0.1" +wasm-bindgen = "0.2" [dependencies.web-sys] version = "0.3" diff --git a/meta/src/html.rs b/meta/src/html.rs index 0742ba6d6..6a841e127 100644 --- a/meta/src/html.rs +++ b/meta/src/html.rs @@ -113,12 +113,15 @@ impl Render for HtmlView { impl RenderHtml for HtmlView { const MIN_LENGTH: usize = 0; - fn to_html_with_buf(self, buf: &mut String, position: &mut Position) {} + fn to_html_with_buf(self, _buf: &mut String, _position: &mut Position) { + // meta tags are rendered into the buffer stored into the context + // the value has already been taken out, when we're on the server + } fn hydrate( self, - cursor: &Cursor, - position: &PositionState, + _cursor: &Cursor, + _position: &PositionState, ) -> Self::State { let el = document() .document_element() @@ -139,15 +142,17 @@ impl Mountable for HtmlViewState { fn mount( &mut self, - parent: &::Element, - marker: Option<&::Node>, + _parent: &::Element, + _marker: Option<&::Node>, ) { + // only sets attributes + // the tag doesn't need to be mounted anywhere, of course } fn insert_before_this( &self, - parent: &::Element, - child: &mut dyn Mountable, + _parent: &::Element, + _child: &mut dyn Mountable, ) -> bool { true } diff --git a/meta/src/lib.rs b/meta/src/lib.rs index a58fd53b3..ea565bdc9 100644 --- a/meta/src/lib.rs +++ b/meta/src/lib.rs @@ -49,50 +49,102 @@ use indexmap::IndexMap; use leptos::{ - debug_warn, + component, debug_warn, reactive_graph::owner::{provide_context, use_context}, tachys::{ - html::attribute::any_attribute::AnyAttribute, renderer::dom::Dom, + dom::document, + html::{ + attribute::{any_attribute::AnyAttribute, Attribute}, + element::{CreateElement, ElementType, HtmlElement}, + }, + hydration::Cursor, + renderer::{dom::Dom, Renderer}, + view::{Mountable, Position, PositionState, Render, RenderHtml}, }, + IntoView, }; +use once_cell::sync::Lazy; +use or_poisoned::OrPoisoned; +use send_wrapper::SendWrapper; use std::{ cell::{Cell, RefCell}, fmt::Debug, rc::Rc, sync::{Arc, RwLock}, }; -#[cfg(any(feature = "csr", feature = "hydrate"))] -use wasm_bindgen::{JsCast, UnwrapThrowExt}; +use wasm_bindgen::JsCast; +use web_sys::{HtmlHeadElement, Node}; mod body; mod html; -/*mod link; +mod link; mod meta_tags; mod script; mod style; -mod stylesheet;*/ +mod stylesheet; mod title; pub use body::*; pub use html::*; -/*pub use link::*; +pub use link::*; pub use meta_tags::*; pub use script::*; pub use style::*; -pub use stylesheet::*;*/ +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, Default, Debug)] +#[derive(Clone, Debug)] pub struct MetaContext { /// Metadata associated with the `` element. - pub title: TitleContext, - /* - /// Other metadata tags. - pub tags: MetaTagsContext, - */ + pub(crate) title: TitleContext, + /// The hydration cursor for the location in the `<head>` for arbitrary tags will be rendered. + pub(crate) cursor: Arc<Lazy<SendWrapper<Cursor<Dom>>>>, +} + +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<Dom>> = || { + 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") + .unchecked_into(), + )) + }; + + let cursor = Arc::new(Lazy::new(build_cursor)); + Self { + title: Default::default(), + cursor, + } + } } /// Contains the state of meta tags for server rendering. @@ -115,10 +167,8 @@ struct ServerMetaContextInner { pub(crate) html: Vec<AnyAttribute<Dom>>, /// Metadata associated with the `<body>` element pub(crate) body: Vec<AnyAttribute<Dom>>, - /* - /// Other metadata tags. - pub tags: MetaTagsContext, - */ + /// HTML for arbitrary tags that will be included in the `<head>` element + pub(crate) head_html: String, } impl Debug for ServerMetaContext { @@ -169,12 +219,207 @@ pub fn use_head() -> MetaContext { } } -impl MetaContext { - /// Creates an empty [`MetaContext`]. - pub fn new() -> Self { - Default::default() +pub(crate) fn register<E, At, Ch>( + el: HtmlElement<E, At, Ch, Dom>, +) -> RegisteredMetaTag<E, At, Ch> +where + HtmlElement<E, At, Ch, Dom>: RenderHtml<Dom>, +{ + let mut el = Some(el); + + if let Some(cx) = use_context::<ServerMetaContext>() { + let mut inner = cx.inner.write().or_poisoned(); + el.take() + .unwrap() + .to_html_with_buf(&mut inner.head_html, &mut Position::NextChild); } + 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: Option<HtmlElement<E, At, Ch, Dom>>, +} + +struct RegisteredMetaTagState<E, At, Ch> +where + HtmlElement<E, At, Ch, Dom>: Render<Dom>, +{ + state: <HtmlElement<E, At, Ch, Dom> as Render<Dom>>::State, +} + +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<Dom> for RegisteredMetaTag<E, At, Ch> +where + E: CreateElement<Dom>, + At: Attribute<Dom>, + Ch: Render<Dom>, +{ + type State = RegisteredMetaTagState<E, At, Ch>; + type FallibleState = RegisteredMetaTagState<E, At, Ch>; + + fn build(self) -> Self::State { + let state = self.el.unwrap().build(); + RegisteredMetaTagState { state } + } + + fn rebuild(self, state: &mut Self::State) { + self.el.unwrap().rebuild(&mut state.state); + } + + fn try_build(self) -> leptos::tachys::error::Result<Self::FallibleState> { + Ok(self.build()) + } + + fn try_rebuild( + self, + state: &mut Self::FallibleState, + ) -> leptos::tachys::error::Result<()> { + self.rebuild(state); + Ok(()) + } +} + +impl<E, At, Ch> RenderHtml<Dom> for RegisteredMetaTag<E, At, Ch> +where + E: ElementType + CreateElement<Dom>, + At: Attribute<Dom>, + Ch: RenderHtml<Dom>, +{ + const MIN_LENGTH: usize = 0; + + fn to_html_with_buf(self, _buf: &mut String, _position: &mut Position) { + // meta tags are rendered into the buffer stored into the context + // the value has already been taken out, when we're on the server + } + + fn hydrate<const FROM_SERVER: bool>( + self, + cursor: &Cursor<Dom>, + 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.unwrap().hydrate::<FROM_SERVER>( + &*cursor, + &PositionState::new(Position::NextChild), + ); + RegisteredMetaTagState { state } + } +} + +impl<E, At, Ch> Mountable<Dom> for RegisteredMetaTagState<E, At, Ch> +where + E: CreateElement<Dom>, + At: Attribute<Dom>, + Ch: Render<Dom>, +{ + fn unmount(&mut self) { + self.state.unmount(); + } + + fn mount( + &mut self, + _parent: &<Dom as Renderer>::Element, + _marker: Option<&<Dom as Renderer>::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, + parent: &<Dom as Renderer>::Element, + child: &mut dyn Mountable<Dom>, + ) -> bool { + self.state.insert_before_this(&document_head(), child) + } +} + +/// 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 { + context: use_context::<ServerMetaContext>().expect( + "before using the <MetaTags/> component, you should make sure to \ + provide ServerMetaContext via context", + ), + } +} + +struct MetaTagsView { + context: ServerMetaContext, +} + +// 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<Dom> for MetaTagsView { + type State = (); + type FallibleState = (); + + fn build(self) -> Self::State {} + + fn rebuild(self, state: &mut Self::State) {} + + fn try_build(self) -> leptos::tachys::error::Result<Self::FallibleState> { + Ok(()) + } + + fn try_rebuild( + self, + state: &mut Self::FallibleState, + ) -> leptos::tachys::error::Result<()> { + Ok(()) + } +} + +impl RenderHtml<Dom> for MetaTagsView { + const MIN_LENGTH: usize = 0; + + fn to_html_with_buf(self, buf: &mut String, position: &mut Position) { + if let Some(title) = self.context.title.as_string() { + buf.reserve(15 + title.len()); + buf.push_str("<title>"); + buf.push_str(&title); + buf.push_str(""); + } + + buf.push_str(&self.context.inner.write().or_poisoned().head_html); + } + + fn hydrate( + self, + cursor: &Cursor, + position: &PositionState, + ) -> Self::State { + } +} + +impl MetaContext { + // TODO remove the below? + #[cfg(feature = "ssr")] /// Converts the existing metadata tags into HTML that can be injected into the document head. /// diff --git a/meta/src/link.rs b/meta/src/link.rs index a01c429e7..887130bf4 100644 --- a/meta/src/link.rs +++ b/meta/src/link.rs @@ -1,5 +1,14 @@ -use crate::use_head; -use leptos::{nonce::use_nonce, *}; +use crate::register; +use leptos::{ + component, + oco::Oco, + prelude::GlobalAttributes, + tachys::{ + html::{attribute::any_attribute::AnyAttribute, element::link}, + renderer::dom::Dom, + }, + IntoView, +}; /// Injects an [`HTMLLinkElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement) into the document /// head, accepting any of the valid attributes for that tag. @@ -24,7 +33,7 @@ use leptos::{nonce::use_nonce, *}; /// } /// } /// ``` -#[component(transparent)] +#[component] pub fn Link( /// The [`id`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-id) attribute. #[prop(optional, into)] @@ -35,9 +44,6 @@ pub fn Link( /// The [`crossorigin`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-crossorigin) attribute. #[prop(optional, into)] crossorigin: Option>, - /// The [`disabled`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-disabled) attribute. - #[prop(optional, into)] - disabled: Option, /// The [`fetchpriority`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-fetchpriority) attribute. #[prop(optional, into)] fetchpriority: Option>, @@ -59,9 +65,6 @@ pub fn Link( /// The [`media`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-media) attribute. #[prop(optional, into)] media: Option>, - /// The [`prefetch`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-prefetch) attribute. - #[prop(optional, into)] - prefetch: Option>, /// The [`referrerpolicy`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-referrerpolicy) attribute. #[prop(optional, into)] referrerpolicy: Option>, @@ -82,42 +85,26 @@ pub fn Link( blocking: Option>, /// Custom attributes. #[prop(attrs, optional)] - attrs: Vec<(&'static str, Attribute)>, + attrs: Vec>, ) -> impl IntoView { - let meta = use_head(); - let next_id = meta.tags.get_next_id(); - let mut id: Oco<'static, str> = - id.unwrap_or_else(|| format!("leptos-link-{}", next_id.0).into()); - - let builder_el = leptos::leptos_dom::html::as_meta_tag({ - let id = id.clone_inplace(); - move || { - attrs - .into_iter() - .fold(leptos::leptos_dom::html::link(), |el, (name, value)| { - el.attr(name, value) - }) - .attr("id", id) - .attr("as", as_) - .attr("crossorigin", crossorigin) - .attr("disabled", disabled.unwrap_or(false)) - .attr("fetchpriority", fetchpriority) - .attr("href", href) - .attr("hreflang", hreflang) - .attr("imagesizes", imagesizes) - .attr("imagesrcset", imagesrcset) - .attr("integrity", integrity) - .attr("media", media) - .attr("prefetch", prefetch) - .attr("referrerpolicy", referrerpolicy) - .attr("rel", rel) - .attr("sizes", sizes) - .attr("title", title) - .attr("type", type_) - .attr("blocking", blocking) - .attr("nonce", use_nonce()) - } - }); - - meta.tags.register(id, builder_el.into_any()); + // TODO additional attributes + register( + link() + .id(id) + .r#as(as_) + .crossorigin(crossorigin) + .fetchpriority(fetchpriority) + .href(href) + .hreflang(hreflang) + .imagesizes(imagesizes) + .imagesrcset(imagesrcset) + .integrity(integrity) + .media(media) + .referrerpolicy(referrerpolicy) + .rel(rel) + .sizes(sizes) + .title(title) + .r#type(type_) + .blocking(blocking), + ) } diff --git a/meta/src/meta_tags.rs b/meta/src/meta_tags.rs index dba6c36a7..73fdb6b6f 100644 --- a/meta/src/meta_tags.rs +++ b/meta/src/meta_tags.rs @@ -1,5 +1,14 @@ -use crate::{use_head, TextProp}; -use leptos::{component, Attribute, IntoView}; +use crate::register; +use leptos::{ + component, + prelude::{CustomAttribute, GlobalAttributes}, + tachys::{ + html::{attribute::any_attribute::AnyAttribute, element::meta}, + renderer::dom::Dom, + }, + text_prop::TextProp, + IntoView, +}; /// Injects an [`HTMLMetaElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMetaElement) into the document /// head to set metadata @@ -21,7 +30,7 @@ use leptos::{component, Attribute, IntoView}; /// } /// } /// ``` -#[component(transparent)] +#[component] pub fn Meta( /// The [`charset`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-charset) attribute. #[prop(optional, into)] @@ -35,29 +44,24 @@ pub fn Meta( /// The [`http-equiv`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-http-equiv) attribute. #[prop(optional, into)] http_equiv: Option, + /// The [`itemprop`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-itemprop) attribute. + #[prop(optional, into)] + itemprop: Option, /// The [`content`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-content) attribute. #[prop(optional, into)] content: Option, /// Custom attributes. #[prop(attrs, optional)] - attrs: Vec<(&'static str, Attribute)>, + attrs: Vec>, ) -> impl IntoView { - let meta = use_head(); - let next_id = meta.tags.get_next_id(); - let id = format!("leptos-link-{}", next_id.0); - - let builder_el = leptos::leptos_dom::html::as_meta_tag(move || { - attrs - .into_iter() - .fold(leptos::leptos_dom::html::meta(), |el, (name, value)| { - el.attr(name, value) - }) - .attr("charset", move || charset.as_ref().map(|v| v.get())) - .attr("name", move || name.as_ref().map(|v| v.get())) - .attr("property", move || property.as_ref().map(|v| v.get())) - .attr("http-equiv", move || http_equiv.as_ref().map(|v| v.get())) - .attr("content", move || content.as_ref().map(|v| v.get())) - }); - - meta.tags.register(id.into(), builder_el.into_any()); + // TODO other attrs + register( + meta() + .charset(charset.map(|v| move || v.get())) + .name(name.map(|v| move || v.get())) + .attr("property", property.map(|v| move || v.get())) + .http_equiv(http_equiv.map(|v| move || v.get())) + .itemprop(itemprop.map(|v| move || v.get())) + .content(content.map(|v| move || v.get())), + ) } diff --git a/meta/src/script.rs b/meta/src/script.rs index dd4c5f5f6..8ca1a006e 100644 --- a/meta/src/script.rs +++ b/meta/src/script.rs @@ -1,5 +1,15 @@ -use crate::use_head; -use leptos::{nonce::use_nonce, *}; +use crate::register; +use leptos::{ + component, + oco::Oco, + prelude::*, + tachys::{ + html::{attribute::any_attribute::AnyAttribute, element::script}, + renderer::dom::Dom, + view::any_view::AnyView, + }, + IntoView, +}; /// Injects an [`HTMLScriptElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement) into the document /// head, accepting any of the valid attributes for that tag. @@ -21,7 +31,7 @@ use leptos::{nonce::use_nonce, *}; /// } /// } /// ``` -#[component(transparent)] +#[component] pub fn Script( /// An ID for the `