pathland_render_html/
document.rs1use std::collections::BTreeMap;
13
14use pathland_core::{component_type, property_id, WindowMeta};
15
16use crate::{escape_html_attr, Node};
17
18pub const DEFAULT_TITLE: &str = "Pathland";
21
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct DocumentMeta {
29 pub title: Option<String>,
31 pub description: Option<String>,
34 pub canonical_url: Option<String>,
36 pub site_name: Option<String>,
38 pub image_url: Option<String>,
41 pub og_type: Option<String>,
43 pub twitter_card: Option<String>,
46 pub twitter_site: Option<String>,
48 pub open_graph: bool,
50 pub twitter: bool,
52 pub structured_data: bool,
54}
55
56impl DocumentMeta {
57 #[must_use]
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 #[must_use]
65 pub fn none() -> Self {
66 Self::default()
67 }
68
69 #[must_use]
72 pub fn seo(canonical_url: impl Into<String>) -> Self {
73 Self {
74 canonical_url: Some(canonical_url.into()),
75 open_graph: true,
76 twitter: true,
77 structured_data: true,
78 ..Self::default()
79 }
80 }
81
82 #[must_use]
84 pub fn with_title(mut self, title: impl Into<String>) -> Self {
85 self.title = Some(title.into());
86 self
87 }
88
89 #[must_use]
91 pub fn with_description(mut self, description: impl Into<String>) -> Self {
92 self.description = Some(description.into());
93 self
94 }
95
96 #[must_use]
98 pub fn with_canonical_url(mut self, canonical_url: impl Into<String>) -> Self {
99 self.canonical_url = Some(canonical_url.into());
100 self
101 }
102
103 #[must_use]
105 pub fn with_site_name(mut self, site_name: impl Into<String>) -> Self {
106 self.site_name = Some(site_name.into());
107 self
108 }
109
110 #[must_use]
112 pub fn with_image_url(mut self, image_url: impl Into<String>) -> Self {
113 self.image_url = Some(image_url.into());
114 self
115 }
116
117 #[must_use]
119 pub fn with_og_type(mut self, og_type: impl Into<String>) -> Self {
120 self.og_type = Some(og_type.into());
121 self
122 }
123
124 #[must_use]
126 pub fn with_twitter_card(mut self, twitter_card: impl Into<String>) -> Self {
127 self.twitter_card = Some(twitter_card.into());
128 self
129 }
130
131 #[must_use]
133 pub fn with_twitter_site(mut self, twitter_site: impl Into<String>) -> Self {
134 self.twitter_site = Some(twitter_site.into());
135 self
136 }
137
138 #[must_use]
140 pub fn with_open_graph(mut self, on: bool) -> Self {
141 self.open_graph = on;
142 self
143 }
144
145 #[must_use]
147 pub fn with_twitter(mut self, on: bool) -> Self {
148 self.twitter = on;
149 self
150 }
151
152 #[must_use]
154 pub fn with_structured_data(mut self, on: bool) -> Self {
155 self.structured_data = on;
156 self
157 }
158}
159
160pub(crate) fn resolve(window: &WindowMeta, meta: &DocumentMeta) -> (String, Option<String>) {
163 let title = window
164 .title
165 .clone()
166 .filter(|value| !value.is_empty())
167 .or_else(|| meta.title.clone().filter(|value| !value.is_empty()))
168 .unwrap_or_else(|| DEFAULT_TITLE.to_string());
169 let description = window
170 .description
171 .clone()
172 .filter(|value| !value.is_empty())
173 .or_else(|| meta.description.clone().filter(|value| !value.is_empty()));
174 (title, description)
175}
176
177pub(crate) fn image_url(
180 meta: &DocumentMeta,
181 nodes: &BTreeMap<u32, Node>,
182 root: u32,
183) -> Option<String> {
184 meta.image_url
185 .clone()
186 .filter(|value| !value.is_empty())
187 .or_else(|| first_image_source(nodes, root).map(str::to_string))
188}
189
190fn canonical_tag(meta: &DocumentMeta) -> String {
192 meta.canonical_url
193 .as_deref()
194 .filter(|url| !url.is_empty())
195 .map(|url| {
196 format!(
197 "<link rel=\"canonical\" href=\"{}\">\n",
198 escape_html_attr(url)
199 )
200 })
201 .unwrap_or_default()
202}
203
204pub(crate) fn head_tags(
207 meta: &DocumentMeta,
208 title: &str,
209 description: Option<&str>,
210 locale: Option<&str>,
211 image: Option<&str>,
212) -> String {
213 let mut out = canonical_tag(meta);
214 let canonical = meta.canonical_url.as_deref().filter(|url| !url.is_empty());
215
216 if meta.open_graph {
217 let og_type = meta
218 .og_type
219 .as_deref()
220 .filter(|value| !value.is_empty())
221 .unwrap_or("website");
222 out.push_str(&format!(
223 "<meta property=\"og:type\" content=\"{}\">\n",
224 escape_html_attr(og_type)
225 ));
226 out.push_str(&format!(
227 "<meta property=\"og:title\" content=\"{}\">\n",
228 escape_html_attr(title)
229 ));
230 if let Some(description) = description {
231 out.push_str(&format!(
232 "<meta property=\"og:description\" content=\"{}\">\n",
233 escape_html_attr(description)
234 ));
235 }
236 if let Some(canonical) = canonical {
237 out.push_str(&format!(
238 "<meta property=\"og:url\" content=\"{}\">\n",
239 escape_html_attr(canonical)
240 ));
241 }
242 if let Some(site_name) = meta.site_name.as_deref().filter(|v| !v.is_empty()) {
243 out.push_str(&format!(
244 "<meta property=\"og:site_name\" content=\"{}\">\n",
245 escape_html_attr(site_name)
246 ));
247 }
248 if let Some(image) = image.filter(|v| !v.is_empty()) {
249 out.push_str(&format!(
250 "<meta property=\"og:image\" content=\"{}\">\n",
251 escape_html_attr(image)
252 ));
253 }
254 if let Some(locale) = locale.filter(|v| !v.is_empty()) {
255 out.push_str(&format!(
256 "<meta property=\"og:locale\" content=\"{}\">\n",
257 escape_html_attr(locale)
258 ));
259 }
260 }
261
262 if meta.twitter {
263 let card = meta
264 .twitter_card
265 .as_deref()
266 .filter(|v| !v.is_empty())
267 .unwrap_or(if image.is_some() {
268 "summary_large_image"
269 } else {
270 "summary"
271 });
272 out.push_str(&format!(
273 "<meta name=\"twitter:card\" content=\"{}\">\n",
274 escape_html_attr(card)
275 ));
276 out.push_str(&format!(
277 "<meta name=\"twitter:title\" content=\"{}\">\n",
278 escape_html_attr(title)
279 ));
280 if let Some(description) = description {
281 out.push_str(&format!(
282 "<meta name=\"twitter:description\" content=\"{}\">\n",
283 escape_html_attr(description)
284 ));
285 }
286 if let Some(image) = image.filter(|v| !v.is_empty()) {
287 out.push_str(&format!(
288 "<meta name=\"twitter:image\" content=\"{}\">\n",
289 escape_html_attr(image)
290 ));
291 }
292 if let Some(site) = meta.twitter_site.as_deref().filter(|v| !v.is_empty()) {
293 out.push_str(&format!(
294 "<meta name=\"twitter:site\" content=\"{}\">\n",
295 escape_html_attr(site)
296 ));
297 }
298 }
299
300 out
301}
302
303pub(crate) fn resolve_url(canonical: Option<&str>, url: &str) -> String {
305 if url.is_empty() {
306 return String::new();
307 }
308 if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("//") {
309 return url.to_string();
310 }
311 if let (Some(canonical), true) = (canonical, url.starts_with('/')) {
312 if let Some(origin) = origin_of(canonical) {
313 return format!("{origin}{url}");
314 }
315 }
316 url.to_string()
317}
318
319fn origin_of(url: &str) -> Option<&str> {
321 let scheme = url.find("://")?;
322 let rest = &url[scheme + 3..];
323 let end = rest.find('/').map_or(url.len(), |i| scheme + 3 + i);
324 Some(&url[..end])
325}
326
327pub(crate) fn json_escape(value: &str) -> String {
331 let mut out = String::with_capacity(value.len());
332 for ch in value.chars() {
333 match ch {
334 '"' => out.push_str("\\\""),
335 '\\' => out.push_str("\\\\"),
336 '\u{08}' => out.push_str("\\b"),
337 '\u{0C}' => out.push_str("\\f"),
338 '\n' => out.push_str("\\n"),
339 '\r' => out.push_str("\\r"),
340 '\t' => out.push_str("\\t"),
341 '<' => out.push_str("\\u003C"),
342 '>' => out.push_str("\\u003E"),
343 '&' => out.push_str("\\u0026"),
344 '\u{2028}' => out.push_str("\\u2028"),
345 '\u{2029}' => out.push_str("\\u2029"),
346 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
347 c => out.push(c),
348 }
349 }
350 out
351}
352
353pub(crate) fn first_image_source<'a>(nodes: &'a BTreeMap<u32, Node>, root: u32) -> Option<&'a str> {
356 let node = nodes.get(&root)?;
357 if node.component == component_type::IMAGE {
358 if let Some(src) = node.strings.get(&property_id::IMAGE_SOURCE) {
359 if !src.is_empty() {
360 return Some(src);
361 }
362 }
363 }
364 node.children
365 .iter()
366 .find_map(|child| first_image_source(nodes, *child))
367}