1use crate::canonical::{absolute_url, apply_slash, strip_tracking};
4use crate::defaults::{MetaDefaults, TrailingSlash};
5use crate::overlay::{MetaOverlay, OverlaySnapshot};
6use crate::page::MetaPage;
7use chrono::{DateTime, Utc};
8use sova_core::Request;
9use serde_json::Value;
10
11#[derive(Debug, Clone, Default)]
12pub struct ResolvedMeta {
13 pub title: Option<String>,
14 pub description: Option<String>,
15 pub image: Option<String>,
16 pub noindex: bool,
17 pub canonical: Option<String>,
18 pub og_type: String,
19 pub site_name: Option<String>,
20 pub twitter_site: Option<String>,
21 pub published: Option<DateTime<Utc>>,
22 pub og_locale: Option<String>,
23 pub og_locale_alternate: Vec<String>,
24 pub hreflang: Vec<(String, String)>,
25 pub jsonld: Vec<Value>,
26}
27
28pub fn resolve_meta(req: &Request) -> ResolvedMeta {
29 let defaults = req
30 .try_state::<MetaDefaults>()
31 .map(|d| (*d).clone())
32 .unwrap_or_default();
33 let page = req.route_meta::<MetaPage>().map(|p| (*p).clone());
34 let overlay = req
35 .get::<MetaOverlay>()
36 .map(|o| o.snapshot())
37 .unwrap_or_default();
38 #[allow(unused_mut)]
39 let mut resolved = resolve_parts(
40 &defaults,
41 page.as_ref(),
42 &overlay,
43 &req.path,
44 req.raw_query(),
45 );
46
47 #[cfg(feature = "i18n")]
48 crate::i18n_meta::enrich(req, &defaults, &mut resolved);
49
50 resolved
51}
52
53pub(crate) fn resolve_parts(
55 defaults: &MetaDefaults,
56 page: Option<&MetaPage>,
57 overlay: &OverlaySnapshot,
58 path: &str,
59 raw_query: &str,
60) -> ResolvedMeta {
61 let page = page.cloned().unwrap_or_default();
62
63 let title_raw = overlay
64 .title
65 .clone()
66 .or(page.title)
67 .or_else(|| defaults.site_name.clone());
68 let title = title_raw.map(|t| apply_title_template(&t, defaults.title_template.as_deref()));
69
70 let description = overlay.description.clone().or(page.description);
71 let image = overlay
72 .image
73 .clone()
74 .or(page.image)
75 .or(defaults.default_image.clone())
76 .map(|img| absolutize_image(defaults, &img));
77
78 let noindex = overlay.noindex.unwrap_or(page.noindex) || defaults.robots_block_all;
79
80 let path = overlay
81 .canonical_path
82 .clone()
83 .or(page.canonical_path)
84 .unwrap_or_else(|| path.to_string());
85 let path = apply_slash(&path, defaults.trailing_slash);
86 let canonical = defaults
87 .public_url
88 .as_ref()
89 .map(|base| absolute_url(base, &strip_tracking(&path, raw_query)));
90
91 let published = overlay.published;
92 let mut og_type = overlay
93 .og_type
94 .clone()
95 .or(page.og_type)
96 .unwrap_or_else(|| "website".into());
97 if published.is_some() && og_type == "website" {
98 og_type = "article".into();
99 }
100
101 let mut jsonld = overlay.jsonld.clone();
102 if let Some(pub_at) = published {
103 if !jsonld.iter().any(|v| {
104 v.get("@type")
105 .and_then(|t| t.as_str())
106 .is_some_and(|t| t == "Article")
107 }) {
108 if let Some(ref headline) = title {
109 use crate::schema::{Article, ToJsonLd};
110 jsonld.push(
111 Article {
112 headline: headline.clone(),
113 date_published: Some(pub_at),
114 description: description.clone(),
115 image: image.clone(),
116 ..Default::default()
117 }
118 .json_ld(),
119 );
120 }
121 }
122 }
123
124 ResolvedMeta {
125 title,
126 description,
127 image,
128 noindex,
129 canonical,
130 og_type,
131 site_name: defaults.site_name.clone(),
132 twitter_site: defaults.twitter_site.clone(),
133 published,
134 og_locale: None,
135 og_locale_alternate: Vec::new(),
136 hreflang: Vec::new(),
137 jsonld,
138 }
139}
140
141fn apply_title_template(title: &str, template: Option<&str>) -> String {
142 match template {
143 Some(t) if t.contains("{}") => t.replacen("{}", title, 1),
144 _ => title.to_string(),
145 }
146}
147
148fn absolutize_image(defaults: &MetaDefaults, img: &str) -> String {
149 if img.starts_with("http://") || img.starts_with("https://") {
150 return img.to_string();
151 }
152 match &defaults.public_url {
153 Some(base) => absolute_url(base, img),
154 None => img.to_string(),
155 }
156}
157
158#[allow(dead_code)]
159pub fn effective_slash(req: &Request) -> TrailingSlash {
160 req.try_state::<MetaDefaults>()
161 .map(|d| d.trailing_slash)
162 .unwrap_or_default()
163}