use std::collections::BTreeMap;
use pathland_core::{component_type, property_id, WindowMeta};
use crate::{escape_html_attr, Node};
pub const DEFAULT_TITLE: &str = "Pathland";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DocumentMeta {
pub title: Option<String>,
pub description: Option<String>,
pub canonical_url: Option<String>,
pub site_name: Option<String>,
pub image_url: Option<String>,
pub og_type: Option<String>,
pub twitter_card: Option<String>,
pub twitter_site: Option<String>,
pub open_graph: bool,
pub twitter: bool,
pub structured_data: bool,
}
impl DocumentMeta {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn none() -> Self {
Self::default()
}
#[must_use]
pub fn seo(canonical_url: impl Into<String>) -> Self {
Self {
canonical_url: Some(canonical_url.into()),
open_graph: true,
twitter: true,
structured_data: true,
..Self::default()
}
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn with_canonical_url(mut self, canonical_url: impl Into<String>) -> Self {
self.canonical_url = Some(canonical_url.into());
self
}
#[must_use]
pub fn with_site_name(mut self, site_name: impl Into<String>) -> Self {
self.site_name = Some(site_name.into());
self
}
#[must_use]
pub fn with_image_url(mut self, image_url: impl Into<String>) -> Self {
self.image_url = Some(image_url.into());
self
}
#[must_use]
pub fn with_og_type(mut self, og_type: impl Into<String>) -> Self {
self.og_type = Some(og_type.into());
self
}
#[must_use]
pub fn with_twitter_card(mut self, twitter_card: impl Into<String>) -> Self {
self.twitter_card = Some(twitter_card.into());
self
}
#[must_use]
pub fn with_twitter_site(mut self, twitter_site: impl Into<String>) -> Self {
self.twitter_site = Some(twitter_site.into());
self
}
#[must_use]
pub fn with_open_graph(mut self, on: bool) -> Self {
self.open_graph = on;
self
}
#[must_use]
pub fn with_twitter(mut self, on: bool) -> Self {
self.twitter = on;
self
}
#[must_use]
pub fn with_structured_data(mut self, on: bool) -> Self {
self.structured_data = on;
self
}
}
pub(crate) fn resolve(window: &WindowMeta, meta: &DocumentMeta) -> (String, Option<String>) {
let title = window
.title
.clone()
.filter(|value| !value.is_empty())
.or_else(|| meta.title.clone().filter(|value| !value.is_empty()))
.unwrap_or_else(|| DEFAULT_TITLE.to_string());
let description = window
.description
.clone()
.filter(|value| !value.is_empty())
.or_else(|| meta.description.clone().filter(|value| !value.is_empty()));
(title, description)
}
pub(crate) fn image_url(
meta: &DocumentMeta,
nodes: &BTreeMap<u32, Node>,
root: u32,
) -> Option<String> {
meta.image_url
.clone()
.filter(|value| !value.is_empty())
.or_else(|| first_image_source(nodes, root).map(str::to_string))
}
fn canonical_tag(meta: &DocumentMeta) -> String {
meta.canonical_url
.as_deref()
.filter(|url| !url.is_empty())
.map(|url| {
format!(
"<link rel=\"canonical\" href=\"{}\">\n",
escape_html_attr(url)
)
})
.unwrap_or_default()
}
pub(crate) fn head_tags(
meta: &DocumentMeta,
title: &str,
description: Option<&str>,
locale: Option<&str>,
image: Option<&str>,
) -> String {
let mut out = canonical_tag(meta);
let canonical = meta.canonical_url.as_deref().filter(|url| !url.is_empty());
if meta.open_graph {
let og_type = meta
.og_type
.as_deref()
.filter(|value| !value.is_empty())
.unwrap_or("website");
out.push_str(&format!(
"<meta property=\"og:type\" content=\"{}\">\n",
escape_html_attr(og_type)
));
out.push_str(&format!(
"<meta property=\"og:title\" content=\"{}\">\n",
escape_html_attr(title)
));
if let Some(description) = description {
out.push_str(&format!(
"<meta property=\"og:description\" content=\"{}\">\n",
escape_html_attr(description)
));
}
if let Some(canonical) = canonical {
out.push_str(&format!(
"<meta property=\"og:url\" content=\"{}\">\n",
escape_html_attr(canonical)
));
}
if let Some(site_name) = meta.site_name.as_deref().filter(|v| !v.is_empty()) {
out.push_str(&format!(
"<meta property=\"og:site_name\" content=\"{}\">\n",
escape_html_attr(site_name)
));
}
if let Some(image) = image.filter(|v| !v.is_empty()) {
out.push_str(&format!(
"<meta property=\"og:image\" content=\"{}\">\n",
escape_html_attr(image)
));
}
if let Some(locale) = locale.filter(|v| !v.is_empty()) {
out.push_str(&format!(
"<meta property=\"og:locale\" content=\"{}\">\n",
escape_html_attr(locale)
));
}
}
if meta.twitter {
let card = meta
.twitter_card
.as_deref()
.filter(|v| !v.is_empty())
.unwrap_or(if image.is_some() {
"summary_large_image"
} else {
"summary"
});
out.push_str(&format!(
"<meta name=\"twitter:card\" content=\"{}\">\n",
escape_html_attr(card)
));
out.push_str(&format!(
"<meta name=\"twitter:title\" content=\"{}\">\n",
escape_html_attr(title)
));
if let Some(description) = description {
out.push_str(&format!(
"<meta name=\"twitter:description\" content=\"{}\">\n",
escape_html_attr(description)
));
}
if let Some(image) = image.filter(|v| !v.is_empty()) {
out.push_str(&format!(
"<meta name=\"twitter:image\" content=\"{}\">\n",
escape_html_attr(image)
));
}
if let Some(site) = meta.twitter_site.as_deref().filter(|v| !v.is_empty()) {
out.push_str(&format!(
"<meta name=\"twitter:site\" content=\"{}\">\n",
escape_html_attr(site)
));
}
}
out
}
pub(crate) fn resolve_url(canonical: Option<&str>, url: &str) -> String {
if url.is_empty() {
return String::new();
}
if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("//") {
return url.to_string();
}
if let (Some(canonical), true) = (canonical, url.starts_with('/')) {
if let Some(origin) = origin_of(canonical) {
return format!("{origin}{url}");
}
}
url.to_string()
}
fn origin_of(url: &str) -> Option<&str> {
let scheme = url.find("://")?;
let rest = &url[scheme + 3..];
let end = rest.find('/').map_or(url.len(), |i| scheme + 3 + i);
Some(&url[..end])
}
pub(crate) fn json_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{08}' => out.push_str("\\b"),
'\u{0C}' => out.push_str("\\f"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'<' => out.push_str("\\u003C"),
'>' => out.push_str("\\u003E"),
'&' => out.push_str("\\u0026"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
c => out.push(c),
}
}
out
}
pub(crate) fn first_image_source<'a>(nodes: &'a BTreeMap<u32, Node>, root: u32) -> Option<&'a str> {
let node = nodes.get(&root)?;
if node.component == component_type::IMAGE {
if let Some(src) = node.strings.get(&property_id::IMAGE_SOURCE) {
if !src.is_empty() {
return Some(src);
}
}
}
node.children
.iter()
.find_map(|child| first_image_source(nodes, *child))
}