use primitives_rs::html::escape_json_text_for_html_script;
use primitives_rs::matcher::Segment;
use serde::Serialize;
use serde::ser::{SerializeMap, Serializer};
use serde_json::value::RawValue;
use crate::build_output::ClientModule;
use crate::document::{
AttrSettings, PreparedDocument, PreparedEl, PreparedHead, PreparedHeadItem, render_el,
render_head_item, write_attrs,
};
use crate::outcome::PublicError;
use crate::search::SearchShape;
pub(crate) struct OrderedObject<'a>(pub(crate) Vec<(&'a str, &'a str)>);
impl OrderedObject<'_> {
fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl Serialize for OrderedObject<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (name, value) in &self.0 {
map.serialize_entry(name, value)?;
}
map.end()
}
}
#[derive(Serialize)]
pub(crate) struct CompiledPatternWire<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) pattern: Option<&'a str>,
pub(crate) segments: &'a [Segment],
}
#[derive(Serialize)]
pub(crate) struct RouteRecordWire<'a> {
pub(crate) pattern: &'a str,
#[serde(skip_serializing_if = "OrderedObject::is_empty")]
pub(crate) params: OrderedObject<'a>,
#[serde(skip_serializing_if = "<[String]>::is_empty")]
pub(crate) splat_values: &'a [String],
pub(crate) compiled_pattern: CompiledPatternWire<'a>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) search_shape: Option<&'a SearchShape>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) search_input: Option<&'a serde_json::Value>,
pub(crate) import_url: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) loader_output: Option<&'a RawValue>,
}
#[derive(Serialize)]
pub(crate) struct FailureWire<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) index: Option<usize>,
pub(crate) status: u16,
#[serde(flatten)]
pub(crate) public_error: Option<&'a PublicError>,
}
#[derive(Serialize)]
pub(crate) struct HeadElWire<'a> {
tag: &'a str,
#[serde(skip_serializing_if = "OrderedObject::is_empty")]
attrs: OrderedObject<'a>,
#[serde(skip_serializing_if = "str::is_empty")]
html: &'a str,
}
#[derive(Serialize)]
#[serde(untagged)]
pub(crate) enum HeadItemWire<'a> {
El(HeadElWire<'a>),
RawHtml { raw_html: &'a str },
}
#[derive(Serialize)]
pub(crate) struct HeadSectionsWire<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<HeadElWire<'a>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
meta: Vec<HeadElWire<'a>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
rest: Vec<HeadItemWire<'a>>,
}
fn to_el_wire(el: &PreparedEl) -> HeadElWire<'_> {
HeadElWire {
tag: &el.tag,
attrs: OrderedObject(
el.attrs
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
.collect(),
),
html: &el.inner_html,
}
}
pub(crate) fn to_head_wire(head: &PreparedHead) -> HeadSectionsWire<'_> {
HeadSectionsWire {
title: head.title.as_ref().map(to_el_wire),
meta: head.meta.iter().map(to_el_wire).collect(),
rest: head
.rest
.iter()
.map(|item| match item {
PreparedHeadItem::El(el) => HeadItemWire::El(to_el_wire(el)),
PreparedHeadItem::RawHtml(raw_html) => HeadItemWire::RawHtml { raw_html },
})
.collect(),
}
}
#[derive(Serialize)]
pub(crate) struct DocumentAttrsWire<'a> {
#[serde(skip_serializing_if = "OrderedObject::is_empty")]
html: OrderedObject<'a>,
#[serde(skip_serializing_if = "OrderedObject::is_empty")]
head: OrderedObject<'a>,
#[serde(skip_serializing_if = "OrderedObject::is_empty")]
body: OrderedObject<'a>,
}
impl DocumentAttrsWire<'_> {
fn is_empty(&self) -> bool {
self.html.is_empty() && self.head.is_empty() && self.body.is_empty()
}
}
pub(crate) fn to_document_attrs_wire(document: &PreparedDocument) -> DocumentAttrsWire<'_> {
DocumentAttrsWire {
html: OrderedObject(document.html_attrs.resolve().collect()),
head: OrderedObject(document.head_attrs.resolve().collect()),
body: OrderedObject(document.body_attrs.resolve().collect()),
}
}
#[derive(Serialize)]
pub(crate) struct NavPayload<'a> {
pub(crate) routes: Vec<RouteRecordWire<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) failure: Option<FailureWire<'a>>,
pub(crate) head: HeadSectionsWire<'a>,
#[serde(skip_serializing_if = "DocumentAttrsWire::is_empty")]
pub(crate) document_attrs: DocumentAttrsWire<'a>,
#[serde(skip_serializing_if = "<[String]>::is_empty")]
pub(crate) deps: &'a [String],
#[serde(skip_serializing_if = "<[String]>::is_empty")]
pub(crate) css_bundles: &'a [String],
#[serde(skip_serializing_if = "<[String]>::is_empty")]
pub(crate) referenced_static_public_asset_urls: &'a [String],
}
#[derive(Serialize)]
pub(crate) struct SsrPayload<'a> {
pub(crate) routes: Vec<RouteRecordWire<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) failure: Option<FailureWire<'a>>,
pub(crate) client_build_id: &'a str,
#[serde(skip_serializing_if = "DocumentAttrsWire::is_empty")]
pub(crate) document_attrs: DocumentAttrsWire<'a>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub(crate) is_dev: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) deployment_id: Option<&'a str>,
}
pub(crate) struct PreparedAssets {
pub(crate) document_dep_urls: Vec<String>,
pub(crate) document_css_bundle_urls: Vec<String>,
pub(crate) document_referenced_static_public_asset_urls: Vec<String>,
pub(crate) nav_dep_urls: Vec<String>,
pub(crate) nav_css_bundle_urls: Vec<String>,
pub(crate) nav_referenced_static_public_asset_urls: Vec<String>,
}
pub(crate) fn prepare_assets<'a>(
client_entry: &ClientModule,
views: impl Iterator<Item = &'a ClientModule> + Clone,
) -> PreparedAssets {
fn collect<'a>(
entry: &[String],
views: impl Iterator<Item = &'a [String]>,
) -> (Vec<String>, Vec<String>) {
let mut document: Vec<String> = Vec::new();
for url in entry {
if !document.contains(url) {
document.push(url.clone());
}
}
let entry_count = document.len();
for urls in views {
for url in urls {
if !document.contains(url) {
document.push(url.clone());
}
}
}
let nav = document[entry_count..].to_vec();
(document, nav)
}
let (document_dep_urls, nav_dep_urls) = collect(
&client_entry.dep_urls,
views.clone().map(|view| view.dep_urls.as_slice()),
);
let (document_css_bundle_urls, nav_css_bundle_urls) = collect(
&client_entry.css_bundle_urls,
views.clone().map(|view| view.css_bundle_urls.as_slice()),
);
let get_preloaded = |urls: &[String]| {
urls.iter()
.filter(|url| static_public_asset_destination(url).is_some())
.cloned()
.collect::<Vec<String>>()
};
let preloaded_view_urls: Vec<Vec<String>> = views
.map(|view| get_preloaded(&view.referenced_static_public_asset_urls))
.collect();
let (document_referenced_static_public_asset_urls, nav_referenced_static_public_asset_urls) =
collect(
&get_preloaded(&client_entry.referenced_static_public_asset_urls),
preloaded_view_urls.iter().map(Vec::as_slice),
);
PreparedAssets {
document_dep_urls,
document_css_bundle_urls,
document_referenced_static_public_asset_urls,
nav_dep_urls,
nav_css_bundle_urls,
nav_referenced_static_public_asset_urls,
}
}
fn static_public_asset_destination(url: &str) -> Option<&'static str> {
let path = url
.split(['?', '#'])
.next()
.unwrap_or(url)
.to_ascii_lowercase();
let extension = path.rsplit_once('.').map_or("", |(_, extension)| extension);
match extension {
"avif" | "bmp" | "gif" | "ico" | "jpg" | "jpeg" | "png" | "svg" | "webp" => Some("image"),
"woff" | "woff2" | "ttf" | "otf" | "ttc" => Some("font"),
_ => None,
}
}
pub(crate) struct DocumentInput<'a> {
pub(crate) html_attrs: &'a AttrSettings,
pub(crate) head_attrs: &'a AttrSettings,
pub(crate) body_attrs: &'a AttrSettings,
pub(crate) head: &'a PreparedHead,
pub(crate) critical_css: &'a str,
pub(crate) dev_head_html: Option<&'a str>,
pub(crate) dev_body_prepend_html: Option<&'a str>,
pub(crate) dev_body_html: Option<&'a str>,
pub(crate) css_bundle_urls: &'a [String],
pub(crate) module_preload_urls: Vec<&'a str>,
pub(crate) referenced_static_public_asset_urls: &'a [String],
pub(crate) entry_import_url: &'a str,
pub(crate) payload_json: &'a str,
}
fn render_start_tag(tag: &str, attrs: &AttrSettings) -> String {
let mut html = format!("<{tag}");
write_attrs(&mut html, attrs.resolve());
html.push('>');
html
}
fn el(tag: &str, attrs: &[(&str, &str)]) -> PreparedEl {
PreparedEl {
tag: tag.to_owned(),
attrs: attrs
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
.collect(),
inner_html: String::new(),
}
}
pub(crate) fn render_document(input: DocumentInput<'_>) -> String {
let mut assets = Vec::new();
if !input.critical_css.is_empty() {
assets
.push(el("style", &[("id", "vorma-critical-css")]).with_inner_html(input.critical_css));
}
for href in input.css_bundle_urls {
assets.push(el(
"link",
&[
("rel", "stylesheet"),
("href", href),
("data-vorma-css-bundle", href),
],
));
}
for href in &input.module_preload_urls {
assets.push(el("link", &[("rel", "modulepreload"), ("href", href)]));
}
for href in input.referenced_static_public_asset_urls {
let Some(destination) = static_public_asset_destination(href) else {
continue;
};
let mut attrs = vec![
("rel", "preload"),
("href", href.as_str()),
("as", destination),
];
if destination != "image" {
attrs.push(("crossorigin", "anonymous"));
}
attrs.push(("data-vorma-static-public-asset", href));
assets.push(el("link", &attrs));
}
let join = |items: Vec<String>| items.join("\n");
let sections = [
(
"title",
input.head.title.as_ref().map(render_el).unwrap_or_default(),
),
(
"meta",
join(input.head.meta.iter().map(render_el).collect()),
),
(
"rest",
join(input.head.rest.iter().map(render_head_item).collect()),
),
("assets", join(assets.iter().map(render_el).collect())),
];
let head_html = sections
.iter()
.map(|(region, content)| {
format!("<meta name=\"vorma-head\" content=\"{region}\">\n{content}")
})
.collect::<Vec<_>>()
.join("\n");
let body_html = [
el("div", &[("id", "vorma-root")]),
el(
"script",
&[("id", "vorma-data-json"), ("type", "application/json")],
)
.with_inner_html(&escape_json_text_for_html_script(input.payload_json)),
el(
"script",
&[("type", "module"), ("src", input.entry_import_url)],
),
]
.iter()
.map(render_el)
.collect::<Vec<_>>()
.join("\n");
[
"<!doctype html>\n",
&render_start_tag("html", input.html_attrs),
&render_start_tag("head", input.head_attrs),
&head_html,
input.dev_head_html.unwrap_or_default(),
"</head>",
&render_start_tag("body", input.body_attrs),
input.dev_body_prepend_html.unwrap_or_default(),
&body_html,
input.dev_body_html.unwrap_or_default(),
"</body></html>",
]
.concat()
}