coil_wasm/output/
metadata.rs1use 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}