Skip to main content

sova_meta/
resolve.rs

1//! Field-level merge: request > route > defaults.
2
3use 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
53/// Resolve without request extensions beyond the overlay snapshot (headers middleware).
54pub(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}