use crate::action::{Action, ActionMeta};
use crate::ctx::{BoxFuture, Ctx};
use crate::i18n::Text;
use crate::render::{Render, escape};
use crate::time::Date;
use std::sync::Arc;
pub const MAX_SITEMAP_URLS: u64 = 50_000;
pub const MAX_SITEMAP_PARTS: u64 = 1_000;
pub const SITEMAP_BATCH: u32 = 1_000;
pub const MAX_URL_LEN: usize = 2_048;
pub const MAX_ALTERNATES: usize = 64;
pub fn valid_hreflang(tag: &str) -> bool {
tag.len() <= 35
&& tag.starts_with(|c: char| c.is_ascii_alphabetic())
&& tag
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Robots {
Index,
NoIndex,
}
pub fn valid_url(url: &str) -> bool {
let clean = url.len() <= MAX_URL_LEN
&& !url.chars().any(|c| {
c.is_control() || c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | '\\' | '`')
});
let shape = match url.strip_prefix("https://") {
Some(rest) => rest
.split(['/', '?', '#'])
.next()
.is_some_and(|host| !host.is_empty() && !host.contains('@')),
None => url.starts_with('/') && !url.starts_with("//"),
};
clean && shape
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Head {
pub(crate) description: Option<Text>,
pub(crate) canonical: Option<String>,
pub(crate) alternates: Vec<(String, String)>,
pub(crate) og_image: Option<String>,
pub(crate) json_ld: Option<serde_json::Value>,
pub(crate) robots: Option<Robots>,
}
pub(crate) fn checked(url: String, what: &str) -> Option<String> {
if valid_url(&url) {
Some(url)
} else {
tracing::warn!("{what} must be an https URL or an app path starting with `/`; ignored");
None
}
}
pub(crate) fn absolute(url: &str, site: &str, prefix: &str) -> String {
if url.starts_with('/') {
format!("{site}{prefix}{url}")
} else {
url.to_string()
}
}
impl Head {
pub(crate) fn render(&self, r: &Render, site: &str, prefix: &str) -> String {
let mut out = String::new();
if let Some(d) = &self.description {
let text = r.text(d);
out.push_str(&format!(
r#"<meta name="description" content="{text}" data-zeyn-head>"#
));
out.push_str(&format!(
r#"<meta property="og:description" content="{text}" data-zeyn-head>"#
));
}
if let Some(url) = &self.canonical {
let url = escape(&absolute(url, site, prefix));
out.push_str(&format!(
r#"<link rel="canonical" href="{url}" data-zeyn-head>"#
));
out.push_str(&format!(
r#"<meta property="og:url" content="{url}" data-zeyn-head>"#
));
}
for (lang, url) in &self.alternates {
let url = escape(&absolute(url, site, prefix));
out.push_str(&format!(
r#"<link rel="alternate" hreflang="{}" href="{url}" data-zeyn-head>"#,
escape(lang)
));
}
if let Some(url) = &self.og_image {
let url = escape(&absolute(url, site, prefix));
out.push_str(&format!(
r#"<meta property="og:image" content="{url}" data-zeyn-head>"#
));
}
if let Some(robots) = self.robots {
let v = match robots {
Robots::Index => "index, follow",
Robots::NoIndex => "noindex",
};
out.push_str(&format!(
r#"<meta name="robots" content="{v}" data-zeyn-head>"#
));
}
if let Some(data) = &self.json_ld {
out.push_str(r#"<script type="application/ld+json" data-zeyn-head>"#);
out.push_str(&json_ld(data));
out.push_str("</script>");
}
out
}
}
pub fn json_ld(data: &serde_json::Value) -> String {
let raw = data.to_string();
let mut out = String::with_capacity(raw.len());
for c in raw.chars() {
match c {
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'&' => out.push_str("\\u0026"),
c => out.push(c),
}
}
out
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SitemapPart {
pub offset: u64,
pub limit: u32,
}
impl SitemapPart {
pub fn of<T: Clone>(&self, all: &[T]) -> Vec<T> {
all.iter()
.skip(self.offset as usize)
.take(self.limit as usize)
.cloned()
.collect()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SitemapUrl {
pub url: String,
pub lastmod: Option<Date>,
}
impl SitemapUrl {
pub fn new(url: impl Into<String>) -> Self {
SitemapUrl {
url: url.into(),
lastmod: None,
}
}
pub fn lastmod(mut self, day: Date) -> Self {
self.lastmod = Some(day);
self
}
}
type List = Arc<
dyn Fn(Ctx, SitemapPart) -> BoxFuture<crate::action::Result<Vec<SitemapUrl>>> + Send + Sync,
>;
#[derive(Clone)]
pub struct Sitemap {
meta: ActionMeta,
list: List,
}
impl Sitemap {
pub fn of<A>(_action: A) -> Self
where
A: Action<Input = SitemapPart, Output = crate::action::Result<Vec<SitemapUrl>>>,
{
let list: List = Arc::new(|ctx, part| Box::pin(async move { A::call(ctx, part).await? }));
Sitemap {
meta: A::META,
list,
}
}
pub fn meta(&self) -> &ActionMeta {
&self.meta
}
pub fn run(
&self,
ctx: Ctx,
part: SitemapPart,
) -> BoxFuture<crate::action::Result<Vec<SitemapUrl>>> {
(self.list)(ctx, part)
}
}