Skip to main content

coil_wasm/output/
metadata.rs

1use std::collections::BTreeMap;
2use std::collections::BTreeSet;
3
4use crate::error::WasmModelError;
5use crate::validation::{require_non_empty, validate_token};
6
7use super::json_ld::{JsonLdNode, RobotsDirective};
8
9#[derive(Debug, Clone, PartialEq, Eq, Default)]
10pub struct TypedMetadata {
11    pub title: Option<String>,
12    pub description: Option<String>,
13    pub canonical_url: Option<String>,
14    pub alternate_urls: BTreeMap<String, String>,
15    pub robots: BTreeSet<RobotsDirective>,
16    pub json_ld: Vec<JsonLdNode>,
17}
18
19impl TypedMetadata {
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    pub fn with_title(mut self, title: impl Into<String>) -> Result<Self, WasmModelError> {
25        self.title = Some(require_non_empty("title", title.into())?);
26        Ok(self)
27    }
28
29    pub fn with_description(
30        mut self,
31        description: impl Into<String>,
32    ) -> Result<Self, WasmModelError> {
33        self.description = Some(require_non_empty("description", description.into())?);
34        Ok(self)
35    }
36
37    pub fn with_canonical_url(
38        mut self,
39        canonical_url: impl Into<String>,
40    ) -> Result<Self, WasmModelError> {
41        self.canonical_url = Some(validate_absolute_url(
42            "canonical_url",
43            canonical_url.into(),
44        )?);
45        Ok(self)
46    }
47
48    pub fn insert_alternate_url(
49        mut self,
50        locale: impl Into<String>,
51        url: impl Into<String>,
52    ) -> Result<Self, WasmModelError> {
53        let locale = validate_token("alternate_url_locale", locale.into())?;
54        let url = validate_absolute_url("alternate_url", url.into())?;
55        self.alternate_urls.insert(locale, url);
56        Ok(self)
57    }
58
59    pub fn with_robot_directive(mut self, directive: RobotsDirective) -> Self {
60        self.robots.insert(directive);
61        self
62    }
63
64    pub fn push_json_ld(mut self, node: JsonLdNode) -> Self {
65        self.json_ld.push(node);
66        self
67    }
68
69    pub fn merge_from(&mut self, other: &Self) {
70        if other.title.is_some() {
71            self.title = other.title.clone();
72        }
73        if other.description.is_some() {
74            self.description = other.description.clone();
75        }
76        if other.canonical_url.is_some() {
77            self.canonical_url = other.canonical_url.clone();
78        }
79        for (locale, url) in &other.alternate_urls {
80            self.alternate_urls.insert(locale.clone(), url.clone());
81        }
82        self.robots.extend(other.robots.iter().copied());
83        self.json_ld.extend(other.json_ld.iter().cloned());
84    }
85
86    pub(crate) fn validate(&self) -> Result<(), WasmModelError> {
87        if self
88            .title
89            .as_ref()
90            .is_some_and(|value| value.trim().is_empty())
91            || self
92                .description
93                .as_ref()
94                .is_some_and(|value| value.trim().is_empty())
95        {
96            return Err(WasmModelError::InvalidTypedReturn {
97                reason: "metadata title and description must be non-empty when present".to_string(),
98            });
99        }
100        if let Some(canonical_url) = &self.canonical_url {
101            let _ = validate_absolute_url("canonical_url", canonical_url.clone())?;
102        }
103        if self
104            .alternate_urls
105            .iter()
106            .any(|(locale, url)| locale.trim().is_empty() || url.trim().is_empty())
107        {
108            return Err(WasmModelError::InvalidTypedReturn {
109                reason: "metadata alternate URLs must be non-empty".to_string(),
110            });
111        }
112        for (locale, url) in &self.alternate_urls {
113            let _ = validate_token("alternate_url_locale", locale.clone())?;
114            let _ = validate_absolute_url("alternate_url", url.clone())?;
115        }
116        for node in &self.json_ld {
117            node.validate()?;
118        }
119        Ok(())
120    }
121}
122
123fn validate_absolute_url(field: &'static str, value: String) -> Result<String, WasmModelError> {
124    let trimmed = require_non_empty(field, value)?;
125    if is_absolute_http_url(&trimmed) {
126        Ok(trimmed)
127    } else {
128        Err(WasmModelError::InvalidTypedReturn {
129            reason: format!("`{field}` must be an absolute URL"),
130        })
131    }
132}
133
134fn is_absolute_http_url(value: &str) -> bool {
135    value.starts_with("https://") || value.starts_with("http://")
136}