use super::*;
use beet_core::prelude::*;
use beet_dom::prelude::*;
#[derive(Component, Reflect)]
#[reflect(Component)]
#[require(InstanceRoot, BeetRoot)]
pub struct HtmlDocument;
impl HtmlDocument {
pub fn parse_bundle(bundle: impl Bundle) -> String {
HtmlFragment::parse_bundle((HtmlDocument, bundle))
}
}
pub(super) fn rearrange_html_document(
mut commands: Commands,
doctypes: Query<&DoctypeNode>,
children: Query<&Children>,
query: Populated<(Entity, Option<&Children>), Added<HtmlDocument>>,
) {
for (doc_entity, doc_children) in query.iter() {
if children
.iter_descendants_inclusive(doc_entity)
.any(|child| doctypes.contains(child))
{
continue;
}
commands.entity(doc_entity).with_children(|parent| {
parent.spawn(DoctypeNode);
parent
.spawn((ElementNode::open(), NodeTag::new("html")))
.with_children(|parent| {
parent.spawn((ElementNode::open(), NodeTag::new("head")));
let mut body = parent
.spawn((ElementNode::open(), NodeTag::new("body")));
if let Some(children) = doc_children {
body.add_children(children);
}
});
});
}
}
pub(super) fn hoist_document_elements(
mut commands: Commands,
constants: Res<HtmlConstants>,
documents: Populated<(Entity, Option<&SnippetRoot>), Added<HtmlDocument>>,
children: Query<&Children>,
node_tags: Query<&NodeTag>,
directives: Query<&HtmlHoistDirective>,
) -> Result {
for (document, idx) in documents.iter() {
let get_idx = || {
idx.map(|idx| idx.to_string())
.unwrap_or_else(|| String::from("unknown location"))
};
let head = children
.iter_descendants(document)
.find(|child| {
node_tags.get(*child).map_or(false, |tag| tag.0 == "head")
})
.ok_or_else(|| {
let idx = get_idx();
bevyhow!(
"Invalid HTML document: no head tag found\nlocation: {idx}"
)
})?;
let body = children
.iter_descendants(document)
.find(|child| {
node_tags.get(*child).map_or(false, |tag| tag.0 == "body")
})
.ok_or_else(|| {
let idx = get_idx();
bevyhow!(
"Invalid HTML document: no body tag found\nlocation: {idx}"
)
})?;
for entity in children.iter_descendants(document) {
match (directives.get(entity), node_tags.get(entity)) {
(Ok(HtmlHoistDirective::Head), _) => {
commands.entity(head).add_child(entity);
}
(Ok(HtmlHoistDirective::Body), _) => {
commands.entity(body).add_child(entity);
}
(Ok(HtmlHoistDirective::None), _) => {
}
(Err(_), Ok(tag))
if constants.hoist_to_head_tags.contains(&tag.0) =>
{
commands.entity(head).add_child(entity);
}
(Err(_), _) => {
}
}
}
}
Ok(())
}
pub(super) fn insert_hydration_scripts(
mut commands: Commands,
html_constants: Res<HtmlConstants>,
children: Query<&Children>,
is_hydrated: Query<
(),
Or<(With<ClientLoadDirective>, With<ClientOnlyDirective>)>,
>,
documents: Populated<Entity, Added<HtmlDocument>>,
) {
for doc in documents.iter().filter(|doc| {
children
.iter_descendants(*doc)
.any(|child| is_hydrated.contains(child))
}) {
commands
.entity(doc)
.with_child(js_runtime_script())
.with_child(event_playback_script(&html_constants))
.with_child(load_wasm_script(&html_constants));
}
}
fn event_playback_script(html_constants: &HtmlConstants) -> impl Bundle {
script(format!(
r#"
globalThis.{event_store} = []
globalThis.{event_handler} = (id,event) => globalThis.{event_store}.push([id, event])
"#,
event_store = html_constants.event_store,
event_handler = html_constants.event_handler,
))
}
fn js_runtime_script() -> impl Bundle {
script(include_str!(
"../../../../crates/beet_core/src/web_utils/js_runtime_browser.js"
))
}
fn load_wasm_script(html_constants: &HtmlConstants) -> impl Bundle {
script(format!(
r#"
import init from '{js_path}'
init({{ module_or_path: '{bin_path}' }})
.catch((error) => {{
if (!error.message.startsWith("Using exceptions for control flow,"))
throw error
}})
"#,
js_path = html_constants.wasm_js_url(),
bin_path = html_constants.wasm_bin_url()
))
}
fn script(content: impl Into<String>) -> impl Bundle {
(
ElementNode::open(),
NodeTag::new("script"),
related!(
Attributes[(AttributeKey::new("type"), TextNode::new("module"),)]
),
children![TextNode::new(content.into())],
)
}
#[cfg(test)]
mod test {
use crate::prelude::*;
use beet_core::prelude::*;
#[test]
fn text() {
HtmlDocument::parse_bundle(rsx! { hello world }).xpect_str(
"<!DOCTYPE html><html><head></head><body>hello world</body></html>",
);
}
#[test]
fn elements() {
HtmlDocument::parse_bundle(rsx! { <br /> }).xpect_str(
"<!DOCTYPE html><html><head></head><body><br/></body></html>",
);
}
#[test]
fn fragment() {
HtmlDocument::parse_bundle(rsx! {
<br />
<br />
})
.xpect_str(
"<!DOCTYPE html><html><head></head><body><br/><br/></body></html>",
);
}
#[test]
fn empty_fragment() {
HtmlDocument::parse_bundle(rsx! {</>}).xpect_str(
"<!DOCTYPE html><html><head></head><body></body></html>",
);
}
#[test]
fn ignores_incomplete() {
HtmlDocument::parse_bundle(rsx! {
<head>
<br />
</head>
<br />
})
.xpect_str(
"<!DOCTYPE html><html><head></head><body><head><br/></head><br/></body></html>",
);
}
#[test]
#[ignore = "noisy"]
#[should_panic(expected = "Invalid HTML document: no body tag found")]
fn partial() {
HtmlDocument::parse_bundle(rsx! {
<!DOCTYPE html>
<html>
<head>
<br />
</head>
</html>
})
.xpect_str(
"<!DOCTYPE html><html><head><br/></head><body></body></html>",
);
}
#[test]
#[cfg(feature = "css")]
fn hoist_style_tag() {
HtmlDocument::parse_bundle(rsx! { <style>foo{}</style> })
.xpect_snapshot();
}
#[test]
fn hoist_script_tag() {
HtmlDocument::parse_bundle(rsx! {
<script></script>
<br />
})
.xpect_snapshot();
}
#[test]
fn hoist_top_tag() {
HtmlDocument::parse_bundle(rsx! {
<script />
<!DOCTYPE html>
<html>
<head></head>
<body></body>
</html>
})
.xpect_str(
"<!DOCTYPE html><html><head><script></script></head><body></body></html>",
);
}
#[test]
fn hoist_directive() {
HtmlDocument::parse_bundle(
rsx! {
<!DOCTYPE html>
<html>
<head>
<br hoist:body />
</head>
<body>
<span hoist:head />
<script hoist:none />
</body>
</html>
},
)
.xpect_str(
"<!DOCTYPE html><html><head><span/></head><body><script></script><br/></body></html>",
);
}
#[test]
fn hydration_scripts() {
HtmlDocument::parse_bundle(rsx! {<div client:load>}).xpect_snapshot();
}
}