feat: hot reloading support for `cargo-leptos` (#592)
This commit is contained in:
parent
1e0adcd89a
commit
55ce805b60
|
@ -4,6 +4,7 @@ members = [
|
|||
"leptos",
|
||||
"leptos_dom",
|
||||
"leptos_config",
|
||||
"leptos_hot_reload",
|
||||
"leptos_macro",
|
||||
"leptos_reactive",
|
||||
"leptos_server",
|
||||
|
@ -29,6 +30,7 @@ version = "0.2.0"
|
|||
[workspace.dependencies]
|
||||
leptos = { path = "./leptos", default-features = false, version = "0.2.0" }
|
||||
leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.0" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.2.0" }
|
||||
leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.0" }
|
||||
leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.0" }
|
||||
leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.0" }
|
||||
|
|
|
@ -6,32 +6,38 @@ use leptos_router::*;
|
|||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
provide_meta_context(cx);
|
||||
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
view! {
|
||||
cx,
|
||||
<Stylesheet id="leptos" href="/pkg/tailwind.css"/>
|
||||
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="" view= move |cx| view! {
|
||||
cx,
|
||||
<main class="my-0 mx-auto max-w-3xl text-center">
|
||||
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
|
||||
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
|
||||
<button
|
||||
class="bg-sky-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
|
||||
on:click=move |_| set_count.update(|count| *count += 1)
|
||||
>
|
||||
{move || if count() == 0 {
|
||||
"Click me!".to_string()
|
||||
} else {
|
||||
count().to_string()
|
||||
}}
|
||||
</button>
|
||||
</main>
|
||||
}/>
|
||||
<Route path="" view= move |cx| view! { cx, <Home/> }/>
|
||||
</Routes>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Home(cx: Scope) -> impl IntoView {
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
view! { cx,
|
||||
<main class="my-0 mx-auto max-w-3xl text-center">
|
||||
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
|
||||
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
|
||||
<button
|
||||
class="bg-amber-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
|
||||
on:click=move |_| set_count.update(|count| *count += 1)
|
||||
>
|
||||
"Something's here | "
|
||||
{move || if count() == 0 {
|
||||
"Click me!".to_string()
|
||||
} else {
|
||||
count().to_string()
|
||||
}}
|
||||
" | Some more text"
|
||||
</button>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ description = "Utilities to help build server integrations for the Leptos web fr
|
|||
[dependencies]
|
||||
futures = "0.3"
|
||||
leptos = { workspace = true, features = ["ssr"] }
|
||||
leptos_hot_reload = { workspace = true }
|
||||
leptos_meta = { workspace = true, features = ["ssr"] }
|
||||
leptos_router = { workspace = true, features = ["ssr"] }
|
||||
leptos_config = { workspace = true }
|
||||
|
|
|
@ -25,6 +25,7 @@ pub fn html_parts(
|
|||
true => format!(
|
||||
r#"
|
||||
<script crossorigin="">(function () {{
|
||||
{}
|
||||
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
|
||||
ws.onmessage = (ev) => {{
|
||||
let msg = JSON.parse(ev.data);
|
||||
|
@ -40,11 +41,15 @@ pub fn html_parts(
|
|||
}});
|
||||
if (!found) console.warn(`CSS hot-reload: Could not find a <link href=/\"${{msg.css}}\"> element`);
|
||||
}};
|
||||
if(msg.view) {{
|
||||
patch(msg.view);
|
||||
}}
|
||||
}};
|
||||
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
|
||||
}})()
|
||||
</script>
|
||||
"#
|
||||
"#,
|
||||
leptos_hot_reload::HOT_RELOAD_JS
|
||||
),
|
||||
false => "".to_string(),
|
||||
};
|
||||
|
|
|
@ -16,11 +16,13 @@ fn simple_ssr_test() {
|
|||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<div id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-8|open--><div \
|
||||
id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
|
||||
id=\"_0-3\">Value: \
|
||||
<!--hk=_0-4o|leptos-dyn-child-start-->0<!\
|
||||
--hk=_0-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-5\">+1</button></div>"
|
||||
id=\"_0-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-8|close-->"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -54,21 +56,25 @@ fn ssr_test_with_components() {
|
|||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<div id=\"_0-1\" \
|
||||
class=\"counters\"><!--hk=_0-1-0o|leptos-counter-start--><div \
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-49|open--><div id=\"_0-1\" \
|
||||
class=\"counters\"><!--hk=_0-1-0o|leptos-counter-start--><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
|
||||
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
|
||||
id=\"_0-1-3\">Value: \
|
||||
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
|
||||
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-1-5\">+1</button></div><!\
|
||||
--hk=_0-1-0c|leptos-counter-end--><!\
|
||||
--hk=_0-1-5-0o|leptos-counter-start--><div \
|
||||
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-38|close--><!--hk=_0-1-0c|leptos-counter-end--><!\
|
||||
--hk=_0-1-5-0o|leptos-counter-start--><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
|
||||
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
|
||||
id=\"_0-1-5-3\">Value: \
|
||||
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
|
||||
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-1-5-5\">+1</button></div><!\
|
||||
--hk=_0-1-5-0c|leptos-counter-end--></div>"
|
||||
--leptos-view|leptos-tests-ssr.rs-38|close--><!\
|
||||
--hk=_0-1-5-0c|leptos-counter-end--></div><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-49|close-->"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -102,22 +108,26 @@ fn ssr_test_with_snake_case_components() {
|
|||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<div id=\"_0-1\" \
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-101|open--><div id=\"_0-1\" \
|
||||
class=\"counters\"><!\
|
||||
--hk=_0-1-0o|leptos-snake-case-counter-start--><div \
|
||||
--hk=_0-1-0o|leptos-snake-case-counter-start--><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
|
||||
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
|
||||
id=\"_0-1-3\">Value: \
|
||||
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
|
||||
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-1-5\">+1</button></div><!\
|
||||
--hk=_0-1-0c|leptos-snake-case-counter-end--><!\
|
||||
--hk=_0-1-5-0o|leptos-snake-case-counter-start--><div \
|
||||
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-90|close--><!--hk=_0-1-0c|leptos-snake-case-counter-end--><!\
|
||||
--hk=_0-1-5-0o|leptos-snake-case-counter-start--><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
|
||||
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
|
||||
id=\"_0-1-5-3\">Value: \
|
||||
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
|
||||
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
|
||||
id=\"_0-1-5-5\">+1</button></div><!\
|
||||
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div>"
|
||||
--leptos-view|leptos-tests-ssr.rs-90|close--><!\
|
||||
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div><!\
|
||||
--leptos-view|leptos-tests-ssr.rs-101|close-->"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -136,7 +146,9 @@ fn test_classes() {
|
|||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<div id=\"_0-1\" class=\"my big red car\"></div>"
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-142|open--><div id=\"_0-1\" \
|
||||
class=\"my big red \
|
||||
car\"></div><!--leptos-view|leptos-tests-ssr.rs-142|close-->"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -158,8 +170,10 @@ fn ssr_with_styles() {
|
|||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<div id=\"_0-1\" class=\" myclass\"><button id=\"_0-2\" \
|
||||
class=\"btn myclass\">-1</button></div>"
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-164|open--><div id=\"_0-1\" \
|
||||
class=\" myclass\"><button id=\"_0-2\" class=\"btn \
|
||||
myclass\">-1</button></div><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-164|close-->"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -178,7 +192,9 @@ fn ssr_option() {
|
|||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<option id=\"_0-1\"></option>"
|
||||
"<!--leptos-view|leptos-tests-ssr.rs-188|open--><option \
|
||||
id=\"_0-1\"></option><!--leptos-view|leptos-tests-ssr.\
|
||||
rs-188|close-->"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -90,6 +90,8 @@ where
|
|||
element,
|
||||
#[cfg(debug_assertions)]
|
||||
span: ::tracing::Span::current(),
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -259,6 +261,8 @@ cfg_if! {
|
|||
pub(crate) span: ::tracing::Span,
|
||||
pub(crate) cx: Scope,
|
||||
pub(crate) element: El,
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) view_marker: Option<String>
|
||||
}
|
||||
// Server needs to build a virtualized DOM tree
|
||||
} else {
|
||||
|
@ -274,7 +278,9 @@ cfg_if! {
|
|||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) children: SmallVec<[View; 4]>,
|
||||
#[educe(Debug(ignore))]
|
||||
pub(crate) prerendered: Option<Cow<'static, str>>
|
||||
pub(crate) prerendered: Option<Cow<'static, str>>,
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) view_marker: Option<String>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -302,7 +308,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
|
|||
cx,
|
||||
element,
|
||||
#[cfg(debug_assertions)]
|
||||
span: ::tracing::Span::current()
|
||||
span: ::tracing::Span::current(),
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker: None
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
|
@ -310,7 +318,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
|
|||
attrs: smallvec![],
|
||||
children: smallvec![],
|
||||
element,
|
||||
prerendered: None
|
||||
prerendered: None,
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker: None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -329,9 +339,18 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
|
|||
children: smallvec![],
|
||||
element,
|
||||
prerendered: Some(html.into()),
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
/// Adds an optional marker indicating the view macro source.
|
||||
pub fn with_view_marker(mut self, marker: impl Into<String>) -> Self {
|
||||
self.view_marker = Some(marker.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Converts this element into [`HtmlElement<AnyElement>`].
|
||||
pub fn into_any(self) -> HtmlElement<AnyElement> {
|
||||
cfg_if! {
|
||||
|
@ -340,7 +359,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
|
|||
cx,
|
||||
element,
|
||||
#[cfg(debug_assertions)]
|
||||
span
|
||||
span,
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker
|
||||
} = self;
|
||||
|
||||
HtmlElement {
|
||||
|
@ -351,7 +372,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
|
|||
is_void: element.is_void(),
|
||||
},
|
||||
#[cfg(debug_assertions)]
|
||||
span
|
||||
span,
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker
|
||||
}
|
||||
} else {
|
||||
let Self {
|
||||
|
@ -359,7 +382,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
|
|||
attrs,
|
||||
children,
|
||||
element,
|
||||
prerendered
|
||||
prerendered,
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker
|
||||
} = self;
|
||||
|
||||
HtmlElement {
|
||||
|
@ -370,8 +395,10 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
|
|||
element: AnyElement {
|
||||
name: element.name(),
|
||||
is_void: element.is_void(),
|
||||
id: element.hydration_id().clone(),
|
||||
id: element.hydration_id().clone()
|
||||
},
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -742,6 +769,8 @@ impl<El: ElementDescriptor> IntoView for HtmlElement<El> {
|
|||
mut attrs,
|
||||
children,
|
||||
prerendered,
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker,
|
||||
..
|
||||
} = self;
|
||||
|
||||
|
@ -760,6 +789,11 @@ impl<El: ElementDescriptor> IntoView for HtmlElement<El> {
|
|||
element.children.extend(children);
|
||||
element.prerendered = prerendered;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
element.view_marker = view_marker;
|
||||
}
|
||||
|
||||
View::Element(element)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,6 +30,24 @@ cfg_if! {
|
|||
map
|
||||
});
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) static VIEW_MARKERS: LazyCell<HashMap<String, web_sys::Comment>> = LazyCell::new(|| {
|
||||
let document = crate::document();
|
||||
let body = document.body().unwrap();
|
||||
let walker = document
|
||||
.create_tree_walker_with_what_to_show(&body, 128)
|
||||
.unwrap();
|
||||
let mut map = HashMap::new();
|
||||
while let Ok(Some(node)) = walker.next_node() {
|
||||
if let Some(content) = node.text_content() {
|
||||
if let Some(id) = content.strip_prefix("leptos-view|") {
|
||||
map.insert(id.into(), node.unchecked_into());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
});
|
||||
|
||||
static IS_HYDRATING: RefCell<LazyCell<bool>> = RefCell::new(LazyCell::new(|| {
|
||||
#[cfg(debug_assertions)]
|
||||
return crate::document().get_element_by_id("_0-0-0").is_some()
|
||||
|
|
|
@ -148,6 +148,9 @@ cfg_if! {
|
|||
pub name: Cow<'static, str>,
|
||||
#[doc(hidden)]
|
||||
pub element: web_sys::HtmlElement,
|
||||
#[cfg(debug_assertions)]
|
||||
/// Optional marker for the view macro source of the element.
|
||||
pub view_marker: Option<String>
|
||||
}
|
||||
|
||||
impl fmt::Debug for Element {
|
||||
|
@ -167,6 +170,9 @@ cfg_if! {
|
|||
children: Vec<View>,
|
||||
prerendered: Option<Cow<'static, str>>,
|
||||
id: HydrationKey,
|
||||
#[cfg(debug_assertions)]
|
||||
/// Optional marker for the view macro source, in debug mode.
|
||||
pub view_marker: Option<String>
|
||||
}
|
||||
|
||||
impl fmt::Debug for Element {
|
||||
|
@ -200,7 +206,12 @@ impl Element {
|
|||
pub fn into_html_element(self, cx: Scope) -> HtmlElement<AnyElement> {
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
{
|
||||
let Self { element, .. } = self;
|
||||
let Self {
|
||||
element,
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker,
|
||||
..
|
||||
} = self;
|
||||
|
||||
let name = element.node_name().to_ascii_lowercase();
|
||||
|
||||
|
@ -215,6 +226,8 @@ impl Element {
|
|||
element,
|
||||
#[cfg(debug_assertions)]
|
||||
span: ::tracing::Span::current(),
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -227,6 +240,8 @@ impl Element {
|
|||
children,
|
||||
id,
|
||||
prerendered,
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker,
|
||||
} = self;
|
||||
|
||||
let element = AnyElement { name, is_void, id };
|
||||
|
@ -237,6 +252,8 @@ impl Element {
|
|||
attrs,
|
||||
children: children.into_iter().collect(),
|
||||
prerendered,
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -258,6 +275,8 @@ impl Element {
|
|||
#[cfg(debug_assertions)]
|
||||
name: el.name(),
|
||||
element: el.as_ref().clone(),
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker: None
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
@ -267,7 +286,9 @@ impl Element {
|
|||
attrs: Default::default(),
|
||||
children: Default::default(),
|
||||
id: el.hydration_id().clone(),
|
||||
prerendered: None
|
||||
prerendered: None,
|
||||
#[cfg(debug_assertions)]
|
||||
view_marker: None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,8 +19,8 @@ type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;
|
|||
/// let html = leptos::ssr::render_to_string(|cx| view! { cx,
|
||||
/// <p>"Hello, world!"</p>
|
||||
/// });
|
||||
/// // static HTML includes some hydration info
|
||||
/// assert_eq!(html, "<p id=\"_0-1\">Hello, world!</p>");
|
||||
/// // trim off the beginning, which has a bunch of hydration info, for comparison
|
||||
/// assert!(html.contains("Hello, world!</p>"));
|
||||
/// # }}
|
||||
/// ```
|
||||
pub fn render_to_string<F, N>(f: F) -> String
|
||||
|
@ -380,7 +380,7 @@ impl View {
|
|||
}
|
||||
}
|
||||
View::Element(el) => {
|
||||
if let Some(prerendered) = el.prerendered {
|
||||
let el_html = if let Some(prerendered) = el.prerendered {
|
||||
prerendered
|
||||
} else {
|
||||
let tag_name = el.name;
|
||||
|
@ -425,6 +425,17 @@ impl View {
|
|||
format!("<{tag_name}{attrs}>{children}</{tag_name}>")
|
||||
.into()
|
||||
}
|
||||
};
|
||||
cfg_if! {
|
||||
if #[cfg(debug_assertions)] {
|
||||
if let Some(id) = el.view_marker {
|
||||
format!("<!--leptos-view|{id}|open-->{el_html}<!--leptos-view|{id}|close-->").into()
|
||||
} else {
|
||||
el_html
|
||||
}
|
||||
} else {
|
||||
el_html
|
||||
}
|
||||
}
|
||||
}
|
||||
View::Transparent(_) => Default::default(),
|
||||
|
|
|
@ -180,6 +180,12 @@ impl View {
|
|||
}
|
||||
}
|
||||
View::Element(el) => {
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(id) = &el.view_marker {
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!("<!--leptos-view|{id}|open-->").into(),
|
||||
));
|
||||
}
|
||||
if let Some(prerendered) = el.prerendered {
|
||||
chunks.push(StreamChunk::Sync(prerendered))
|
||||
} else {
|
||||
|
@ -234,6 +240,12 @@ impl View {
|
|||
));
|
||||
}
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(id) = &el.view_marker {
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!("<!--leptos-view|{id}|close-->").into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
View::Transparent(_) => {}
|
||||
View::CoreComponent(node) => {
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "leptos_hot_reload"
|
||||
version = { workspace = true }
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/leptos-rs/leptos"
|
||||
description = "Utility types used for dev mode and hot-reloading for the Leptos web framework."
|
||||
readme = "../README.md"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
syn = { version = "1", features = [
|
||||
"full",
|
||||
"parsing",
|
||||
"extra-traits",
|
||||
"visit",
|
||||
"printing",
|
||||
] }
|
||||
quote = "1"
|
||||
syn-rsx = "0.9"
|
||||
proc-macro2 = { version = "1", features = ["span-locations", "nightly"] }
|
||||
parking_lot = "0.12"
|
||||
walkdir = "2"
|
||||
camino = "1.1.3"
|
||||
indexmap = "1.9.2"
|
|
@ -0,0 +1,550 @@
|
|||
use crate::node::{LAttributeValue, LNode};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// TODO: insertion and removal code are still somewhat broken
|
||||
// namely, it will tend to remove and move or mutate nodes,
|
||||
// which causes a bit of a problem for DynChild etc.
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct OldChildren(IndexMap<LNode, Vec<usize>>);
|
||||
|
||||
impl LNode {
|
||||
pub fn diff(&self, other: &LNode) -> Vec<Patch> {
|
||||
let mut old_children = OldChildren::default();
|
||||
self.add_old_children(vec![], &mut old_children);
|
||||
self.diff_at(other, &[], &old_children)
|
||||
}
|
||||
|
||||
fn to_replacement_node(
|
||||
&self,
|
||||
old_children: &OldChildren,
|
||||
) -> ReplacementNode {
|
||||
match old_children.0.get(self) {
|
||||
// if the child already exists in the DOM, we can pluck it out
|
||||
// and move it around
|
||||
Some(path) => ReplacementNode::Path(path.to_owned()),
|
||||
// otherwise, we should generate some HTML
|
||||
// but we need to do this recursively in case we're replacing an element
|
||||
// with children who need to be plucked out
|
||||
None => match self {
|
||||
LNode::Fragment(fragment) => ReplacementNode::Fragment(
|
||||
fragment
|
||||
.iter()
|
||||
.map(|node| node.to_replacement_node(old_children))
|
||||
.collect(),
|
||||
),
|
||||
LNode::Element {
|
||||
name,
|
||||
attrs,
|
||||
children,
|
||||
} => ReplacementNode::Element {
|
||||
name: name.to_owned(),
|
||||
attrs: attrs
|
||||
.iter()
|
||||
.filter_map(|(name, value)| match value {
|
||||
LAttributeValue::Boolean => {
|
||||
Some((name.to_owned(), name.to_owned()))
|
||||
}
|
||||
LAttributeValue::Static(value) => {
|
||||
Some((name.to_owned(), value.to_owned()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|node| node.to_replacement_node(old_children))
|
||||
.collect(),
|
||||
},
|
||||
LNode::Text(_)
|
||||
| LNode::Component(_, _)
|
||||
| LNode::DynChild(_) => ReplacementNode::Html(self.to_html()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn add_old_children(&self, path: Vec<usize>, positions: &mut OldChildren) {
|
||||
match self {
|
||||
LNode::Fragment(frag) => {
|
||||
for (idx, child) in frag.iter().enumerate() {
|
||||
let mut new_path = path.clone();
|
||||
new_path.push(idx);
|
||||
child.add_old_children(new_path, positions);
|
||||
}
|
||||
}
|
||||
LNode::Element { children, .. } => {
|
||||
for (idx, child) in children.iter().enumerate() {
|
||||
let mut new_path = path.clone();
|
||||
new_path.push(idx);
|
||||
child.add_old_children(new_path, positions);
|
||||
}
|
||||
}
|
||||
// only need to insert dynamic content, as these might change
|
||||
LNode::Component(_, _) | LNode::DynChild(_) => {
|
||||
positions.0.insert(self.clone(), path);
|
||||
}
|
||||
// can just create text nodes, whatever
|
||||
LNode::Text(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_at(
|
||||
&self,
|
||||
other: &LNode,
|
||||
path: &[usize],
|
||||
orig_children: &OldChildren,
|
||||
) -> Vec<Patch> {
|
||||
if std::mem::discriminant(self) != std::mem::discriminant(other) {
|
||||
return vec![Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::ReplaceWith(
|
||||
other.to_replacement_node(orig_children),
|
||||
),
|
||||
}];
|
||||
}
|
||||
match (self, other) {
|
||||
// fragment: diff children
|
||||
(LNode::Fragment(old), LNode::Fragment(new)) => {
|
||||
LNode::diff_children(path, old, new, orig_children)
|
||||
}
|
||||
// text node: replace text
|
||||
(LNode::Text(_), LNode::Text(new)) => vec![Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::SetText(new.to_owned()),
|
||||
}],
|
||||
// elements
|
||||
(
|
||||
LNode::Element {
|
||||
name: old_name,
|
||||
attrs: old_attrs,
|
||||
children: old_children,
|
||||
},
|
||||
LNode::Element {
|
||||
name: new_name,
|
||||
attrs: new_attrs,
|
||||
children: new_children,
|
||||
},
|
||||
) => {
|
||||
let tag_patch = (old_name != new_name).then(|| Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::ChangeTagName(new_name.to_owned()),
|
||||
});
|
||||
|
||||
let attrs_patch = LNode::diff_attrs(path, old_attrs, new_attrs);
|
||||
|
||||
let children_patch = LNode::diff_children(
|
||||
path,
|
||||
old_children,
|
||||
new_children,
|
||||
orig_children,
|
||||
);
|
||||
|
||||
attrs_patch
|
||||
.into_iter()
|
||||
// tag patch comes second so we remove old attrs before copying them over
|
||||
.chain(tag_patch)
|
||||
.chain(children_patch)
|
||||
.collect()
|
||||
}
|
||||
// components + dynamic context: no patches
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_attrs<'a>(
|
||||
path: &'a [usize],
|
||||
old: &'a [(String, LAttributeValue)],
|
||||
new: &'a [(String, LAttributeValue)],
|
||||
) -> impl Iterator<Item = Patch> + 'a {
|
||||
let additions = new
|
||||
.iter()
|
||||
.filter_map(|(name, new_value)| {
|
||||
let old_attr = old.iter().find(|(o_name, _)| o_name == name);
|
||||
let replace = match old_attr {
|
||||
None => true,
|
||||
Some((_, old_value)) if old_value != new_value => true,
|
||||
_ => false,
|
||||
};
|
||||
if replace {
|
||||
match &new_value {
|
||||
LAttributeValue::Boolean => {
|
||||
Some((name.to_owned(), "".to_string()))
|
||||
}
|
||||
LAttributeValue::Static(s) => {
|
||||
Some((name.to_owned(), s.to_owned()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|(name, value)| Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::SetAttribute(name, value),
|
||||
});
|
||||
|
||||
let removals = old.iter().filter_map(|(name, _)| {
|
||||
if !new.iter().any(|(new_name, _)| new_name == name) {
|
||||
Some(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::RemoveAttribute(name.to_owned()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
additions.chain(removals)
|
||||
}
|
||||
|
||||
fn diff_children(
|
||||
path: &[usize],
|
||||
old: &[LNode],
|
||||
new: &[LNode],
|
||||
old_children: &OldChildren,
|
||||
) -> Vec<Patch> {
|
||||
if old.is_empty() && new.is_empty() {
|
||||
vec![]
|
||||
} else if old.is_empty() {
|
||||
vec![Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::AppendChildren(
|
||||
new.iter()
|
||||
.map(LNode::to_html)
|
||||
.map(ReplacementNode::Html)
|
||||
.collect(),
|
||||
),
|
||||
}]
|
||||
} else if new.is_empty() {
|
||||
vec![Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::ClearChildren,
|
||||
}]
|
||||
} else {
|
||||
let mut a = 0;
|
||||
let mut b = std::cmp::max(old.len(), new.len()) - 1; // min is 0, have checked both have items
|
||||
let mut patches = vec![];
|
||||
// common prefix
|
||||
while a < b {
|
||||
let old = old.get(a);
|
||||
let new = new.get(a);
|
||||
|
||||
match (old, new) {
|
||||
(None, None) => {}
|
||||
(None, Some(new)) => patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::InsertChild {
|
||||
before: a,
|
||||
child: new.to_replacement_node(old_children),
|
||||
},
|
||||
}),
|
||||
(Some(_), None) => patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::RemoveChild { at: a },
|
||||
}),
|
||||
(Some(old), Some(new)) => {
|
||||
if old != new {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a += 1;
|
||||
}
|
||||
|
||||
// common suffix
|
||||
while b >= a {
|
||||
let old = old.get(b);
|
||||
let new = new.get(b);
|
||||
|
||||
match (old, new) {
|
||||
(None, None) => {}
|
||||
(None, Some(new)) => patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::InsertChildAfter {
|
||||
after: b - 1,
|
||||
child: new.to_replacement_node(old_children),
|
||||
},
|
||||
}),
|
||||
(Some(_), None) => patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::RemoveChild { at: b },
|
||||
}),
|
||||
(Some(old), Some(new)) => {
|
||||
if old != new {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if b == 0 {
|
||||
break;
|
||||
} else {
|
||||
b -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// diffing in middle
|
||||
if b >= a {
|
||||
let old_slice_end =
|
||||
if b >= old.len() { old.len() - 1 } else { b };
|
||||
let new_slice_end =
|
||||
if b >= new.len() { new.len() - 1 } else { b };
|
||||
let old = &old[a..=old_slice_end];
|
||||
let new = &new[a..=new_slice_end];
|
||||
|
||||
for (new_idx, new_node) in new.iter().enumerate() {
|
||||
match old.get(new_idx) {
|
||||
Some(old_node) => {
|
||||
let mut new_path = path.to_vec();
|
||||
new_path.push(new_idx + a);
|
||||
let diffs = old_node.diff_at(
|
||||
new_node,
|
||||
&new_path,
|
||||
old_children,
|
||||
);
|
||||
patches.extend(&mut diffs.into_iter());
|
||||
}
|
||||
None => patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::InsertChild {
|
||||
before: new_idx,
|
||||
child: new_node
|
||||
.to_replacement_node(old_children),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
patches
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Patches(pub Vec<(String, Vec<Patch>)>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Patch {
|
||||
path: Vec<usize>,
|
||||
action: PatchAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PatchAction {
|
||||
ReplaceWith(ReplacementNode),
|
||||
ChangeTagName(String),
|
||||
RemoveAttribute(String),
|
||||
SetAttribute(String, String),
|
||||
SetText(String),
|
||||
ClearChildren,
|
||||
AppendChildren(Vec<ReplacementNode>),
|
||||
RemoveChild {
|
||||
at: usize,
|
||||
},
|
||||
InsertChild {
|
||||
before: usize,
|
||||
child: ReplacementNode,
|
||||
},
|
||||
InsertChildAfter {
|
||||
after: usize,
|
||||
child: ReplacementNode,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReplacementNode {
|
||||
Html(String),
|
||||
Path(Vec<usize>),
|
||||
Fragment(Vec<ReplacementNode>),
|
||||
Element {
|
||||
name: String,
|
||||
attrs: Vec<(String, String)>,
|
||||
children: Vec<ReplacementNode>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
diff::{Patch, PatchAction, ReplacementNode},
|
||||
node::LAttributeValue,
|
||||
LNode,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn patches_text() {
|
||||
let a = LNode::Text("foo".into());
|
||||
let b = LNode::Text("bar".into());
|
||||
let delta = a.diff(&b);
|
||||
assert_eq!(
|
||||
delta,
|
||||
vec![Patch {
|
||||
path: vec![],
|
||||
action: PatchAction::SetText("bar".into())
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patches_attrs() {
|
||||
let a = LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![
|
||||
("class".into(), LAttributeValue::Static("a".into())),
|
||||
("type".into(), LAttributeValue::Static("button".into())),
|
||||
],
|
||||
children: vec![],
|
||||
};
|
||||
let b = LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![
|
||||
("class".into(), LAttributeValue::Static("a b".into())),
|
||||
("id".into(), LAttributeValue::Static("button".into())),
|
||||
],
|
||||
children: vec![],
|
||||
};
|
||||
let delta = a.diff(&b);
|
||||
assert_eq!(
|
||||
delta,
|
||||
vec![
|
||||
Patch {
|
||||
path: vec![],
|
||||
action: PatchAction::SetAttribute(
|
||||
"class".into(),
|
||||
"a b".into()
|
||||
)
|
||||
},
|
||||
Patch {
|
||||
path: vec![],
|
||||
action: PatchAction::SetAttribute(
|
||||
"id".into(),
|
||||
"button".into()
|
||||
)
|
||||
},
|
||||
Patch {
|
||||
path: vec![],
|
||||
action: PatchAction::RemoveAttribute("type".into())
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patches_child_text() {
|
||||
let a = LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![
|
||||
LNode::Text("foo".into()),
|
||||
LNode::Text("bar".into()),
|
||||
],
|
||||
};
|
||||
let b = LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![
|
||||
LNode::Text("foo".into()),
|
||||
LNode::Text("baz".into()),
|
||||
],
|
||||
};
|
||||
let delta = a.diff(&b);
|
||||
assert_eq!(
|
||||
delta,
|
||||
vec![Patch {
|
||||
path: vec![1],
|
||||
action: PatchAction::SetText("baz".into())
|
||||
},]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_child() {
|
||||
let a = LNode::Element {
|
||||
name: "div".into(),
|
||||
attrs: vec![],
|
||||
children: vec![LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![LNode::Text("bar".into())],
|
||||
}],
|
||||
};
|
||||
let b = LNode::Element {
|
||||
name: "div".into(),
|
||||
attrs: vec![],
|
||||
children: vec![
|
||||
LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![LNode::Text("foo".into())],
|
||||
},
|
||||
LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![LNode::Text("bar".into())],
|
||||
},
|
||||
],
|
||||
};
|
||||
let delta = a.diff(&b);
|
||||
assert_eq!(
|
||||
delta,
|
||||
vec![
|
||||
Patch {
|
||||
path: vec![],
|
||||
action: PatchAction::InsertChildAfter {
|
||||
after: 0,
|
||||
child: ReplacementNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![ReplacementNode::Html("bar".into())]
|
||||
}
|
||||
}
|
||||
},
|
||||
Patch {
|
||||
path: vec![0, 0],
|
||||
action: PatchAction::SetText("foo".into())
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_child() {
|
||||
let a = LNode::Element {
|
||||
name: "div".into(),
|
||||
attrs: vec![],
|
||||
children: vec![
|
||||
LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![LNode::Text("foo".into())],
|
||||
},
|
||||
LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![LNode::Text("bar".into())],
|
||||
},
|
||||
],
|
||||
};
|
||||
let b = LNode::Element {
|
||||
name: "div".into(),
|
||||
attrs: vec![],
|
||||
children: vec![LNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![LNode::Text("foo".into())],
|
||||
}],
|
||||
};
|
||||
let delta = a.diff(&b);
|
||||
assert_eq!(
|
||||
delta,
|
||||
vec![Patch {
|
||||
path: vec![],
|
||||
action: PatchAction::RemoveChild { at: 1 }
|
||||
},]
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
extern crate proc_macro;
|
||||
|
||||
use anyhow::Result;
|
||||
use camino::Utf8PathBuf;
|
||||
use diff::Patches;
|
||||
use node::LNode;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use syn::{
|
||||
spanned::Spanned,
|
||||
visit::{self, Visit},
|
||||
Macro,
|
||||
};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub mod diff;
|
||||
pub mod node;
|
||||
pub mod parsing;
|
||||
|
||||
pub const HOT_RELOAD_JS: &str = include_str!("patch.js");
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ViewMacros {
|
||||
// keyed by original location identifier
|
||||
views: Arc<RwLock<HashMap<Utf8PathBuf, Vec<MacroInvocation>>>>,
|
||||
}
|
||||
|
||||
impl ViewMacros {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn update_from_paths<T: AsRef<Path>>(&self, paths: &[T]) -> Result<()> {
|
||||
let mut views = HashMap::new();
|
||||
|
||||
for path in paths {
|
||||
for entry in WalkDir::new(path).into_iter().flatten() {
|
||||
if entry.file_type().is_file() {
|
||||
let path: PathBuf = entry.path().into();
|
||||
let path = Utf8PathBuf::try_from(path)?;
|
||||
if path.extension() == Some("rs") || path.ends_with(".rs") {
|
||||
let macros = Self::parse_file(&path)?;
|
||||
let entry = views.entry(path.clone()).or_default();
|
||||
*entry = macros;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*self.views.write() = views;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_file(path: &Utf8PathBuf) -> Result<Vec<MacroInvocation>> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)?;
|
||||
let ast = syn::parse_file(&content)?;
|
||||
|
||||
let mut visitor = ViewMacroVisitor::default();
|
||||
visitor.visit_file(&ast);
|
||||
let mut views = Vec::new();
|
||||
for view in visitor.views {
|
||||
let span = view.span();
|
||||
let id = span_to_stable_id(path, span);
|
||||
let mut tokens = view.tokens.clone().into_iter();
|
||||
tokens.next(); // cx
|
||||
tokens.next(); // ,
|
||||
// TODO handle class = ...
|
||||
let rsx =
|
||||
syn_rsx::parse2(tokens.collect::<proc_macro2::TokenStream>())?;
|
||||
let template = LNode::parse_view(rsx)?;
|
||||
views.push(MacroInvocation { id, template })
|
||||
}
|
||||
Ok(views)
|
||||
}
|
||||
|
||||
pub fn patch(&self, path: &Utf8PathBuf) -> Result<Option<Patches>> {
|
||||
let new_views = Self::parse_file(path)?;
|
||||
let mut lock = self.views.write();
|
||||
let diffs = match lock.get(path) {
|
||||
None => return Ok(None),
|
||||
Some(current_views) => {
|
||||
if current_views.len() == new_views.len() {
|
||||
let mut diffs = Vec::new();
|
||||
for (current_view, new_view) in
|
||||
current_views.iter().zip(&new_views)
|
||||
{
|
||||
if current_view.id == new_view.id
|
||||
&& current_view.template != new_view.template
|
||||
{
|
||||
diffs.push((
|
||||
current_view.id.clone(),
|
||||
current_view.template.diff(&new_view.template),
|
||||
));
|
||||
}
|
||||
}
|
||||
diffs
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// update the status to the new views
|
||||
lock.insert(path.clone(), new_views);
|
||||
|
||||
Ok(Some(Patches(diffs)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MacroInvocation {
|
||||
id: String,
|
||||
template: LNode,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MacroInvocation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("MacroInvocation")
|
||||
.field("id", &self.id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ViewMacroVisitor<'a> {
|
||||
views: Vec<&'a Macro>,
|
||||
}
|
||||
|
||||
impl<'ast> Visit<'ast> for ViewMacroVisitor<'ast> {
|
||||
fn visit_macro(&mut self, node: &'ast Macro) {
|
||||
let ident = node.path.get_ident().map(|n| n.to_string());
|
||||
if ident == Some("view".to_string()) {
|
||||
self.views.push(node);
|
||||
}
|
||||
|
||||
// Delegate to the default impl to visit any nested functions.
|
||||
visit::visit_macro(self, node);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn span_to_stable_id(
|
||||
path: impl AsRef<Path>,
|
||||
site: proc_macro2::Span,
|
||||
) -> String {
|
||||
let file = path
|
||||
.as_ref()
|
||||
.to_str()
|
||||
.unwrap_or_default()
|
||||
.replace(['/', '\\'], "-");
|
||||
let start = site.start();
|
||||
format!("{}-{:?}", file, start.line)
|
||||
}
|
|
@ -0,0 +1,168 @@
|
|||
use crate::parsing::{is_component_node, value_to_string};
|
||||
use anyhow::Result;
|
||||
use quote::quote;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use syn_rsx::Node;
|
||||
|
||||
// A lightweight virtual DOM structure we can use to hold
|
||||
// the state of a Leptos view macro template. This is because
|
||||
// `syn` types are `!Send` so we can't store them as we might like.
|
||||
// This is only used to diff view macros for hot reloading so it's very minimal
|
||||
// and ignores many of the data types.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum LNode {
|
||||
Fragment(Vec<LNode>),
|
||||
Text(String),
|
||||
Element {
|
||||
name: String,
|
||||
attrs: Vec<(String, LAttributeValue)>,
|
||||
children: Vec<LNode>,
|
||||
},
|
||||
// don't need anything; skipped during patching because it should
|
||||
// contain its own view macros
|
||||
Component(String, Vec<(String, String)>),
|
||||
DynChild(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum LAttributeValue {
|
||||
Boolean,
|
||||
Static(String),
|
||||
// safely ignored
|
||||
Dynamic,
|
||||
Noop,
|
||||
}
|
||||
|
||||
impl LNode {
|
||||
pub fn parse_view(nodes: Vec<Node>) -> Result<LNode> {
|
||||
let mut out = Vec::new();
|
||||
for node in nodes {
|
||||
LNode::parse_node(node, &mut out)?;
|
||||
}
|
||||
if out.len() == 1 {
|
||||
Ok(out.pop().unwrap())
|
||||
} else {
|
||||
Ok(LNode::Fragment(out))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_node(node: Node, views: &mut Vec<LNode>) -> Result<()> {
|
||||
match node {
|
||||
Node::Fragment(frag) => {
|
||||
for child in frag.children {
|
||||
LNode::parse_node(child, views)?;
|
||||
}
|
||||
}
|
||||
Node::Text(text) => {
|
||||
if let Some(value) = value_to_string(&text.value) {
|
||||
views.push(LNode::Text(value));
|
||||
} else {
|
||||
let value = text.value.as_ref();
|
||||
let code = quote! { #value };
|
||||
let code = code.to_string();
|
||||
views.push(LNode::DynChild(code));
|
||||
}
|
||||
}
|
||||
Node::Block(block) => {
|
||||
let value = block.value.as_ref();
|
||||
let code = quote! { #value };
|
||||
let code = code.to_string();
|
||||
views.push(LNode::DynChild(code));
|
||||
}
|
||||
Node::Element(el) => {
|
||||
if is_component_node(&el) {
|
||||
views.push(LNode::Component(
|
||||
el.name.to_string(),
|
||||
el.attributes
|
||||
.into_iter()
|
||||
.filter_map(|attr| match attr {
|
||||
Node::Attribute(attr) => Some((
|
||||
attr.key.to_string(),
|
||||
format!("{:#?}", attr.value),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
} else {
|
||||
let name = el.name.to_string();
|
||||
let mut attrs = Vec::new();
|
||||
|
||||
for attr in el.attributes {
|
||||
if let Node::Attribute(attr) = attr {
|
||||
let name = attr.key.to_string();
|
||||
if let Some(value) =
|
||||
attr.value.as_ref().and_then(value_to_string)
|
||||
{
|
||||
attrs.push((
|
||||
name,
|
||||
LAttributeValue::Static(value),
|
||||
));
|
||||
} else {
|
||||
attrs.push((name, LAttributeValue::Dynamic));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut children = Vec::new();
|
||||
for child in el.children {
|
||||
LNode::parse_node(child, &mut children)?;
|
||||
}
|
||||
|
||||
views.push(LNode::Element {
|
||||
name,
|
||||
attrs,
|
||||
children,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_html(&self) -> String {
|
||||
match self {
|
||||
LNode::Fragment(frag) => frag.iter().map(LNode::to_html).collect(),
|
||||
LNode::Text(text) => text.to_owned(),
|
||||
LNode::Component(name, _) => format!(
|
||||
"<!--<{name}>--><pre><{name}/> will load once Rust code \
|
||||
has been compiled.</pre><!--</{name}>-->"
|
||||
),
|
||||
LNode::DynChild(_) => "<!--<DynChild>--><pre>Dynamic content will \
|
||||
load once Rust code has been \
|
||||
compiled.</pre><!--</DynChild>-->"
|
||||
.to_string(),
|
||||
LNode::Element {
|
||||
name,
|
||||
attrs,
|
||||
children,
|
||||
} => {
|
||||
// this is naughty, but the browsers are tough and can handle it
|
||||
// I wouldn't do this for real code, but this is just for dev mode
|
||||
let is_self_closing = children.is_empty();
|
||||
|
||||
let attrs = attrs
|
||||
.iter()
|
||||
.filter_map(|(name, value)| match value {
|
||||
LAttributeValue::Boolean => Some(format!("{name} ")),
|
||||
LAttributeValue::Static(value) => {
|
||||
Some(format!("{name}=\"{value}\" "))
|
||||
}
|
||||
LAttributeValue::Dynamic => None,
|
||||
LAttributeValue::Noop => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
let children =
|
||||
children.iter().map(LNode::to_html).collect::<String>();
|
||||
|
||||
if is_self_closing {
|
||||
format!("<{name} {attrs}/>")
|
||||
} else {
|
||||
format!("<{name} {attrs}>{children}</{name}>")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
use syn_rsx::{NodeElement, NodeValueExpr};
|
||||
|
||||
pub fn value_to_string(value: &NodeValueExpr) -> Option<String> {
|
||||
match &value.as_ref() {
|
||||
syn::Expr::Lit(lit) => match &lit.lit {
|
||||
syn::Lit::Str(s) => Some(s.value()),
|
||||
syn::Lit::Char(c) => Some(c.value().to_string()),
|
||||
syn::Lit::Int(i) => Some(i.base10_digits().to_string()),
|
||||
syn::Lit::Float(f) => Some(f.base10_digits().to_string()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_component_node(node: &NodeElement) -> bool {
|
||||
node.name
|
||||
.to_string()
|
||||
.starts_with(|c: char| c.is_ascii_uppercase())
|
||||
}
|
|
@ -0,0 +1,285 @@
|
|||
console.log("[HOT RELOADING] Connected to server.");
|
||||
function patch(json) {
|
||||
try {
|
||||
const views = JSON.parse(json);
|
||||
for ([id, patches] of views) {
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT),
|
||||
open = `leptos-view|${id}|open`,
|
||||
close = `leptos-view|${id}|close`;
|
||||
let start, end;
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.textContent == open) {
|
||||
start = walker.currentNode;
|
||||
} else if (walker.currentNode.textContent == close) {
|
||||
end = walker.currentNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// build tree of current actual children
|
||||
const range = new Range();
|
||||
range.setStartAfter(start);
|
||||
range.setEndBefore(end);
|
||||
const actualChildren = buildActualChildren(start.parentElement, range);
|
||||
const actions = [];
|
||||
|
||||
// build up the set of actions
|
||||
for (const patch of patches) {
|
||||
const child = childAtPath(
|
||||
actualChildren.length > 1 ? { children: actualChildren } : actualChildren[0],
|
||||
patch.path
|
||||
);
|
||||
const action = patch.action;
|
||||
if (action == "ClearChildren") {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > ClearChildren", child.node);
|
||||
child.node.textContent = ""
|
||||
});
|
||||
} else if (action.ReplaceWith) {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > ReplaceWith", child, action.ReplaceWith);
|
||||
const replacement = fromReplacementNode(action.ReplaceWith, actualChildren);
|
||||
if (child.node) {
|
||||
child.node.replaceWith(replacement)
|
||||
} else {
|
||||
const range = new Range();
|
||||
range.setStartAfter(child.start);
|
||||
range.setEndAfter(child.end);
|
||||
range.deleteContents();
|
||||
child.start.replaceWith(replacement);
|
||||
}
|
||||
});
|
||||
} else if (action.ChangeTagName) {
|
||||
const oldNode = child.node;
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > ChangeTagName", child.node, action.ChangeTagName);
|
||||
const newElement = document.createElement(action.ChangeTagName);
|
||||
for (const attr of oldNode.attributes) {
|
||||
newElement.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
for (const childNode of child.node.childNodes) {
|
||||
newElement.appendChild(childNode);
|
||||
}
|
||||
|
||||
child.node.replaceWith(newElement)
|
||||
});
|
||||
} else if (action.RemoveAttribute) {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > RemoveAttribute", child.node, action.RemoveAttribute);
|
||||
child.node.removeAttribute(action.RemoveAttribute);
|
||||
});
|
||||
} else if (action.SetAttribute) {
|
||||
const [name, value] = action.SetAttribute;
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > SetAttribute", child.node, action.SetAttribute);
|
||||
child.node.setAttribute(name, value);
|
||||
});
|
||||
} else if (action.SetText) {
|
||||
const node = child.node;
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > SetText", child.node, action.SetText);
|
||||
node.textContent = action.SetText
|
||||
});
|
||||
} else if (action.AppendChildren) {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > AppendChildren", child.node, action.AppendChildren);
|
||||
const newChildren = fromReplacementNode(action.AppendChildren, actualChildren);
|
||||
child.node.append(newChildren);
|
||||
});
|
||||
} else if (action.RemoveChild) {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > RemoveChild", child.node, child.children, action.RemoveChild);
|
||||
const toRemove = child.children[action.RemoveChild.at];
|
||||
let toRemoveNode = toRemove.node;
|
||||
if (!toRemoveNode) {
|
||||
const range = new Range();
|
||||
range.setStartBefore(toRemove.start);
|
||||
range.setEndAfter(toRemove.end);
|
||||
toRemoveNode = range.deleteContents();
|
||||
} else {
|
||||
toRemoveNode.parentNode.removeChild(toRemoveNode);
|
||||
}
|
||||
})
|
||||
} else if (action.InsertChild) {
|
||||
const newChild = fromReplacementNode(action.InsertChild.child, actualChildren),
|
||||
before = child.children[action.InsertChild.before];
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > InsertChild", child, child.node, action.InsertChild, " before ", before);
|
||||
if (!before) {
|
||||
child.node.appendChild(newChild);
|
||||
} else {
|
||||
child.node.insertBefore(newChild, (before.node || before.start));
|
||||
}
|
||||
})
|
||||
} else if (action.InsertChildAfter) {
|
||||
const newChild = fromReplacementNode(action.InsertChildAfter.child, actualChildren),
|
||||
after = child.children[action.InsertChildAfter.after];
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > InsertChildAfter", child, child.node, action.InsertChildAfter, " after ", after);
|
||||
console.log("newChild is ", newChild);
|
||||
if (!after || !(after.node || after.start).nextSibling) {
|
||||
child.node.appendChild(newChild);
|
||||
} else {
|
||||
child.node.insertBefore(newChild, (after.node || after.start).nextSibling);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.warn("[HOT RELOADING] Unmatched action", action);
|
||||
}
|
||||
}
|
||||
|
||||
// actually run the actions
|
||||
// the reason we delay them is so that children aren't moved before other children are found, etc.
|
||||
for (const action of actions) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[HOT RELOADING] Error: ", e);
|
||||
}
|
||||
|
||||
function fromReplacementNode(node, actualChildren) {
|
||||
console.log("fromReplacementNode", node, actualChildren);
|
||||
if (node.Html) {
|
||||
return fromHTML(node.Html);
|
||||
}
|
||||
else if (node.Fragment) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const child of node.Fragment) {
|
||||
frag.appendChild(fromReplacementNode(child, actualChildren));
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
else if (node.Element) {
|
||||
const element = document.createElement(node.Element.name);
|
||||
for (const [name, value] of node.Element.attrs) {
|
||||
element.setAttribute(name, value);
|
||||
}
|
||||
for (const child of node.Element.children) {
|
||||
element.appendChild(fromReplacementNode(child, actualChildren));
|
||||
}
|
||||
return element;
|
||||
}
|
||||
else {
|
||||
const child = childAtPath(
|
||||
actualChildren.length > 1 ? { children: actualChildren } : actualChildren[0],
|
||||
node.Path
|
||||
);
|
||||
console.log("fromReplacementNode", child, "\n", node, actualChildren);
|
||||
if (child) {
|
||||
let childNode = child.node;
|
||||
if (!childNode) {
|
||||
const range = new Range();
|
||||
range.setStartBefore(child.start);
|
||||
range.setEndAfter(child.end);
|
||||
// okay this is somewhat silly
|
||||
// if we do cloneContents() here to return it,
|
||||
// we strip away the event listeners
|
||||
// if we're moving just one object, this is less than ideal
|
||||
// so I'm actually going to *extract* them, then clone and reinsert
|
||||
/* const toReinsert = range.cloneContents();
|
||||
if (child.end.nextSibling) {
|
||||
child.end.parentNode.insertBefore(toReinsert, child.end.nextSibling);
|
||||
} else {
|
||||
child.end.parentNode.appendChild(toReinsert);
|
||||
} */
|
||||
childNode = range.cloneContents();
|
||||
}
|
||||
return childNode;
|
||||
} else {
|
||||
console.warn("[HOT RELOADING] Could not find replacement node at ", node.Path);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildActualChildren(element, range) {
|
||||
const walker = document.createTreeWalker(
|
||||
element,
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT,
|
||||
{
|
||||
acceptNode(node) {
|
||||
return node.parentNode == element && (!range || range.isPointInRange(node, 0))
|
||||
}
|
||||
}
|
||||
);
|
||||
const actualChildren = [],
|
||||
elementCount = {};
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.nodeType == Node.ELEMENT_NODE) {
|
||||
if (elementCount[walker.currentNode.nodeName]) {
|
||||
elementCount[walker.currentNode.nodeName] += 1;
|
||||
} else {
|
||||
elementCount[walker.currentNode.nodeName] = 0;
|
||||
}
|
||||
elementCount[walker.currentNode.nodeName];
|
||||
|
||||
actualChildren.push({
|
||||
type: "element",
|
||||
name: walker.currentNode.nodeName,
|
||||
number: elementCount[walker.currentNode.nodeName],
|
||||
node: walker.currentNode,
|
||||
children: buildActualChildren(walker.currentNode)
|
||||
});
|
||||
} else if (walker.currentNode.nodeType == Node.TEXT_NODE) {
|
||||
actualChildren.push({
|
||||
type: "text",
|
||||
node: walker.currentNode
|
||||
});
|
||||
} else if (walker.currentNode.nodeType == Node.COMMENT_NODE) {
|
||||
if (walker.currentNode.textContent.trim().startsWith("leptos-view")) {
|
||||
} else if (walker.currentNode.textContent.trim() == "<() />") {
|
||||
actualChildren.push({
|
||||
type: "unit",
|
||||
node: walker.currentNode
|
||||
});
|
||||
} else if (walker.currentNode.textContent.trim() == "<DynChild>") {
|
||||
let start = walker.currentNode;
|
||||
while (walker.currentNode.textContent.trim() !== "</DynChild>") {
|
||||
walker.nextNode();
|
||||
}
|
||||
let end = walker.currentNode;
|
||||
actualChildren.push({
|
||||
type: "dyn-child",
|
||||
start, end
|
||||
});
|
||||
} else if (walker.currentNode.textContent.trim().startsWith("<")) {
|
||||
let componentName = walker.currentNode.textContent.trim();
|
||||
let endMarker = componentName.replace("<", "</");
|
||||
let start = walker.currentNode;
|
||||
while (walker.currentNode.textContent.trim() !== endMarker) {
|
||||
walker.nextSibling();
|
||||
}
|
||||
let end = walker.currentNode;
|
||||
actualChildren.push({
|
||||
type: "component",
|
||||
start, end
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("[HOT RELOADING] Building children, encountered", walker.currentNode);
|
||||
}
|
||||
}
|
||||
return actualChildren;
|
||||
}
|
||||
|
||||
function childAtPath(element, path) {
|
||||
if (path.length == 0) {
|
||||
return element;
|
||||
} else if (element.children) {
|
||||
const next = element.children[path[0]],
|
||||
rest = path.slice(1);
|
||||
return childAtPath(next, rest);
|
||||
} else if (path == [0]) {
|
||||
return element;
|
||||
} else {
|
||||
console.warn("[HOT RELOADING] Child at ", path, "not found in ", element);
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
function fromHTML(html) {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = html;
|
||||
return template.content.cloneNode(true);
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ quote = "1"
|
|||
syn = { version = "1", features = ["full"] }
|
||||
syn-rsx = "0.9"
|
||||
leptos_dom = { workspace = true }
|
||||
leptos_hot_reload = { workspace = true }
|
||||
leptos_reactive = { workspace = true }
|
||||
leptos_server = { workspace = true }
|
||||
server_fn_macro = { workspace = true }
|
||||
|
|
|
@ -9,7 +9,7 @@ use proc_macro2::TokenTree;
|
|||
use quote::ToTokens;
|
||||
use server_fn_macro::{server_macro_impl, ServerContext};
|
||||
use syn::parse_macro_input;
|
||||
use syn_rsx::{parse, NodeAttribute, NodeElement};
|
||||
use syn_rsx::{parse, NodeAttribute};
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
|
@ -284,6 +284,7 @@ pub fn view(tokens: TokenStream) -> TokenStream {
|
|||
let tokens: proc_macro2::TokenStream = tokens.into();
|
||||
let mut tokens = tokens.into_iter();
|
||||
let (cx, comma) = (tokens.next(), tokens.next());
|
||||
|
||||
match (cx, comma) {
|
||||
(Some(TokenTree::Ident(cx)), Some(TokenTree::Punct(punct)))
|
||||
if punct.as_char() == ',' =>
|
||||
|
@ -328,6 +329,7 @@ pub fn view(tokens: TokenStream) -> TokenStream {
|
|||
&nodes,
|
||||
Mode::default(),
|
||||
global_class.as_ref(),
|
||||
normalized_call_site(proc_macro::Span::call_site()),
|
||||
),
|
||||
Err(error) => error.to_compile_error(),
|
||||
}
|
||||
|
@ -342,6 +344,20 @@ pub fn view(tokens: TokenStream) -> TokenStream {
|
|||
}
|
||||
}
|
||||
|
||||
fn normalized_call_site(site: proc_macro::Span) -> Option<String> {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(all(debug_assertions, not(feature = "stable")))] {
|
||||
Some(leptos_hot_reload::span_to_stable_id(
|
||||
site.source_file().path(),
|
||||
site.into()
|
||||
))
|
||||
} else {
|
||||
_ = site;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An optimized, cached template for client-side rendering. Follows the same
|
||||
/// syntax as the [view!] macro. In hydration or server-side rendering mode,
|
||||
/// behaves exactly as the `view` macro. In client-side rendering mode, uses a `<template>`
|
||||
|
@ -704,12 +720,6 @@ pub fn params_derive(
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_component_node(node: &NodeElement) -> bool {
|
||||
node.name
|
||||
.to_string()
|
||||
.starts_with(|c: char| c.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
pub(crate) fn attribute_value(attr: &NodeAttribute) -> &syn::Expr {
|
||||
match &attr.value {
|
||||
Some(value) => value.as_ref(),
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use crate::{attribute_value, is_component_node};
|
||||
use crate::attribute_value;
|
||||
use leptos_hot_reload::parsing::is_component_node;
|
||||
use proc_macro2::{Ident, Span, TokenStream};
|
||||
use quote::{quote, quote_spanned};
|
||||
use syn::spanned::Spanned;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use crate::{attribute_value, is_component_node, Mode};
|
||||
use crate::{attribute_value, Mode};
|
||||
use leptos_hot_reload::parsing::{is_component_node, value_to_string};
|
||||
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
|
||||
use quote::{format_ident, quote, quote_spanned};
|
||||
use syn::{spanned::Spanned, Expr, ExprLit, ExprPath, Lit};
|
||||
|
@ -146,6 +147,7 @@ pub(crate) fn render_view(
|
|||
nodes: &[Node],
|
||||
mode: Mode,
|
||||
global_class: Option<&TokenTree>,
|
||||
call_site: Option<String>,
|
||||
) -> TokenStream {
|
||||
if mode == Mode::Ssr {
|
||||
match nodes.len() {
|
||||
|
@ -155,7 +157,9 @@ pub(crate) fn render_view(
|
|||
span => leptos::leptos_dom::Unit
|
||||
}
|
||||
}
|
||||
1 => root_node_to_tokens_ssr(cx, &nodes[0], global_class),
|
||||
1 => {
|
||||
root_node_to_tokens_ssr(cx, &nodes[0], global_class, call_site)
|
||||
}
|
||||
_ => fragment_to_tokens_ssr(
|
||||
cx,
|
||||
Span::call_site(),
|
||||
|
@ -171,7 +175,13 @@ pub(crate) fn render_view(
|
|||
span => leptos::leptos_dom::Unit
|
||||
}
|
||||
}
|
||||
1 => node_to_tokens(cx, &nodes[0], TagType::Unknown, global_class),
|
||||
1 => node_to_tokens(
|
||||
cx,
|
||||
&nodes[0],
|
||||
TagType::Unknown,
|
||||
global_class,
|
||||
call_site,
|
||||
),
|
||||
_ => fragment_to_tokens(
|
||||
cx,
|
||||
Span::call_site(),
|
||||
|
@ -188,6 +198,7 @@ fn root_node_to_tokens_ssr(
|
|||
cx: &Ident,
|
||||
node: &Node,
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> TokenStream {
|
||||
match node {
|
||||
Node::Fragment(fragment) => fragment_to_tokens_ssr(
|
||||
|
@ -211,7 +222,7 @@ fn root_node_to_tokens_ssr(
|
|||
}
|
||||
}
|
||||
Node::Element(node) => {
|
||||
root_element_to_tokens_ssr(cx, node, global_class)
|
||||
root_element_to_tokens_ssr(cx, node, global_class, view_marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -223,7 +234,7 @@ fn fragment_to_tokens_ssr(
|
|||
global_class: Option<&TokenTree>,
|
||||
) -> TokenStream {
|
||||
let nodes = nodes.iter().map(|node| {
|
||||
let node = root_node_to_tokens_ssr(cx, node, global_class);
|
||||
let node = root_node_to_tokens_ssr(cx, node, global_class, None);
|
||||
quote! {
|
||||
#node.into_view(#cx)
|
||||
}
|
||||
|
@ -241,6 +252,7 @@ fn root_element_to_tokens_ssr(
|
|||
cx: &Ident,
|
||||
node: &NodeElement,
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> TokenStream {
|
||||
if is_component_node(node) {
|
||||
component_to_tokens(cx, node, global_class)
|
||||
|
@ -265,10 +277,10 @@ fn root_element_to_tokens_ssr(
|
|||
}
|
||||
} else {
|
||||
quote! {
|
||||
format!(
|
||||
#template,
|
||||
#(#holes)*
|
||||
)
|
||||
format!(
|
||||
#template,
|
||||
#(#holes)*
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -298,10 +310,15 @@ fn root_element_to_tokens_ssr(
|
|||
leptos::leptos_dom::#typed_element_name::default()
|
||||
}
|
||||
};
|
||||
let view_marker = if let Some(marker) = view_marker {
|
||||
quote! { .with_view_marker(#marker) }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
quote! {
|
||||
{
|
||||
#(#exprs_for_compiler)*
|
||||
::leptos::HtmlElement::from_html(cx, #full_name, #template)
|
||||
::leptos::HtmlElement::from_html(cx, #full_name, #template)#view_marker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -429,19 +446,6 @@ fn element_to_tokens_ssr(
|
|||
}
|
||||
}
|
||||
|
||||
fn value_to_string(value: &syn_rsx::NodeValueExpr) -> Option<String> {
|
||||
match &value.as_ref() {
|
||||
syn::Expr::Lit(lit) => match &lit.lit {
|
||||
syn::Lit::Str(s) => Some(s.value()),
|
||||
syn::Lit::Char(c) => Some(c.value().to_string()),
|
||||
syn::Lit::Int(i) => Some(i.base10_digits().to_string()),
|
||||
syn::Lit::Float(f) => Some(f.base10_digits().to_string()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// returns `inner_html`
|
||||
fn attribute_to_tokens_ssr<'a>(
|
||||
cx: &Ident,
|
||||
|
@ -634,7 +638,7 @@ fn fragment_to_tokens(
|
|||
global_class: Option<&TokenTree>,
|
||||
) -> TokenStream {
|
||||
let nodes = nodes.iter().map(|node| {
|
||||
let node = node_to_tokens(cx, node, parent_type, global_class);
|
||||
let node = node_to_tokens(cx, node, parent_type, global_class, None);
|
||||
|
||||
quote! {
|
||||
#node.into_view(#cx)
|
||||
|
@ -664,6 +668,7 @@ fn node_to_tokens(
|
|||
node: &Node,
|
||||
parent_type: TagType,
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> TokenStream {
|
||||
match node {
|
||||
Node::Fragment(fragment) => fragment_to_tokens(
|
||||
|
@ -687,7 +692,7 @@ fn node_to_tokens(
|
|||
}
|
||||
Node::Attribute(node) => attribute_to_tokens(cx, node),
|
||||
Node::Element(node) => {
|
||||
element_to_tokens(cx, node, parent_type, global_class)
|
||||
element_to_tokens(cx, node, parent_type, global_class, view_marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -697,6 +702,7 @@ fn element_to_tokens(
|
|||
node: &NodeElement,
|
||||
mut parent_type: TagType,
|
||||
global_class: Option<&TokenTree>,
|
||||
view_marker: Option<String>,
|
||||
) -> TokenStream {
|
||||
if is_component_node(node) {
|
||||
component_to_tokens(cx, node, global_class)
|
||||
|
@ -778,7 +784,7 @@ fn element_to_tokens(
|
|||
}
|
||||
}
|
||||
Node::Element(node) => {
|
||||
element_to_tokens(cx, node, parent_type, global_class)
|
||||
element_to_tokens(cx, node, parent_type, global_class, None)
|
||||
}
|
||||
Node::Comment(_) | Node::Doctype(_) | Node::Attribute(_) => {
|
||||
quote! {}
|
||||
|
@ -788,11 +794,17 @@ fn element_to_tokens(
|
|||
.child((#cx, #child))
|
||||
}
|
||||
});
|
||||
let view_marker = if let Some(marker) = view_marker {
|
||||
quote! { .with_view_marker(#marker) }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
quote! {
|
||||
#name
|
||||
#(#attrs)*
|
||||
#global_class_expr
|
||||
#(#children)*
|
||||
#view_marker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -250,12 +250,11 @@ impl MetaContext {
|
|||
/// };
|
||||
///
|
||||
/// // `app` contains only the body content w/ hydration stuff, not the meta tags
|
||||
/// assert_eq!(
|
||||
/// app.into_view(cx).render_to_string(cx),
|
||||
/// "<main id=\"_0-1\"><!--hk=_0-2c|leptos-unit--><!--hk=_0-4c|leptos-unit--><p id=\"_0-5\">Some text</p></main>"
|
||||
/// assert!(
|
||||
/// !app.into_view(cx).render_to_string(cx).contains("my title")
|
||||
/// );
|
||||
/// // `MetaContext::dehydrate()` gives you HTML that should be in the `<head>`
|
||||
/// assert_eq!(use_head(cx).dehydrate(), "<title>my title</title><link id=\"leptos-link-1\" href=\"/style.css\" rel=\"stylesheet\" leptos-hk=\"_0-3\"/>")
|
||||
/// assert!(use_head(cx).dehydrate().contains("<title>my title</title>"))
|
||||
/// });
|
||||
/// # }
|
||||
/// ```
|
||||
|
|
Loading…
Reference in New Issue