1use std::io::Write;
6
7use chrono::{DateTime, Utc};
8use thiserror::Error;
9use tracing::debug;
10use typstify_core::{Config, Page};
11
12#[derive(Debug, Error)]
14pub enum SitemapError {
15 #[error("IO error: {0}")]
17 Io(#[from] std::io::Error),
18
19 #[error("XML encoding error: {0}")]
21 Xml(String),
22}
23
24pub type Result<T> = std::result::Result<T, SitemapError>;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ChangeFreq {
30 Always,
31 Hourly,
32 Daily,
33 Weekly,
34 Monthly,
35 Yearly,
36 Never,
37}
38
39impl ChangeFreq {
40 fn as_str(&self) -> &'static str {
41 match self {
42 Self::Always => "always",
43 Self::Hourly => "hourly",
44 Self::Daily => "daily",
45 Self::Weekly => "weekly",
46 Self::Monthly => "monthly",
47 Self::Yearly => "yearly",
48 Self::Never => "never",
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct SitemapUrl {
56 pub loc: String,
58
59 pub lastmod: Option<DateTime<Utc>>,
61
62 pub changefreq: Option<ChangeFreq>,
64
65 pub priority: Option<f32>,
67
68 pub alternates: Vec<AlternateLink>,
70}
71
72#[derive(Debug, Clone)]
74pub struct AlternateLink {
75 pub hreflang: String,
77
78 pub href: String,
80}
81
82#[derive(Debug)]
84pub struct SitemapGenerator<'a> {
85 config: &'a Config,
86}
87
88impl<'a> SitemapGenerator<'a> {
89 #[must_use]
91 pub fn new(config: &'a Config) -> Self {
92 Self { config }
93 }
94
95 pub fn generate(&self, pages: &[&Page]) -> Result<String> {
97 debug!(count = pages.len(), "generating sitemap");
98
99 let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
100 xml.push('\n');
101 xml.push_str(r#"<?xml-stylesheet type="text/xsl" href="/sitemap-style.xsl"?>"#);
103 xml.push('\n');
104 xml.push_str(r#"<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9""#);
105
106 let all_languages = self.config.all_languages();
108 if all_languages.len() > 1 {
109 xml.push_str(r#" xmlns:xhtml="http://www.w3.org/1999/xhtml""#);
110 }
111 xml.push_str(">\n");
112
113 for page in pages {
114 let url = self.page_to_url(page);
115 xml.push_str(&self.url_to_xml(&url));
116 }
117
118 xml.push_str("</urlset>\n");
119
120 Ok(xml)
121 }
122
123 fn page_to_url(&self, page: &Page) -> SitemapUrl {
125 let loc = format!("{}{}", self.config.base_url(), page.url);
126
127 let lastmod = page.updated.or(page.date);
129
130 let (changefreq, priority) = if page.url == "/" || page.url.is_empty() {
132 (Some(ChangeFreq::Daily), Some(1.0))
134 } else if page.date.is_some() {
135 (Some(ChangeFreq::Monthly), Some(0.8))
137 } else {
138 (Some(ChangeFreq::Yearly), Some(0.5))
140 };
141
142 let slug = page.url.trim_start_matches('/');
144 let all_languages = self.config.all_languages();
145 let alternates = if all_languages.len() > 1 {
146 all_languages
147 .iter()
148 .map(|lang| {
149 let href = if *lang == self.config.site.default_language {
150 format!("{}/{}", self.config.base_url(), slug)
151 } else {
152 format!("{}/{}/{}", self.config.base_url(), lang, slug)
153 };
154 AlternateLink {
155 hreflang: lang.to_string(),
156 href,
157 }
158 })
159 .collect()
160 } else {
161 Vec::new()
162 };
163
164 SitemapUrl {
165 loc,
166 lastmod,
167 changefreq,
168 priority,
169 alternates,
170 }
171 }
172
173 fn url_to_xml(&self, url: &SitemapUrl) -> String {
175 let mut xml = String::from(" <url>\n");
176
177 xml.push_str(&format!(" <loc>{}</loc>\n", escape_xml(&url.loc)));
178
179 if let Some(lastmod) = &url.lastmod {
180 xml.push_str(&format!(
181 " <lastmod>{}</lastmod>\n",
182 lastmod.format("%Y-%m-%d")
183 ));
184 }
185
186 if let Some(changefreq) = &url.changefreq {
187 xml.push_str(&format!(
188 " <changefreq>{}</changefreq>\n",
189 changefreq.as_str()
190 ));
191 }
192
193 if let Some(priority) = &url.priority {
194 xml.push_str(&format!(" <priority>{priority:.1}</priority>\n"));
195 }
196
197 for alt in &url.alternates {
199 xml.push_str(&format!(
200 r#" <xhtml:link rel="alternate" hreflang="{}" href="{}" />"#,
201 alt.hreflang,
202 escape_xml(&alt.href)
203 ));
204 xml.push('\n');
205 }
206
207 xml.push_str(" </url>\n");
208 xml
209 }
210
211 pub fn write_to<W: Write>(&self, pages: &[&Page], writer: &mut W) -> Result<()> {
213 let xml = self.generate(pages)?;
214 writer.write_all(xml.as_bytes())?;
215 Ok(())
216 }
217
218 pub fn generate_index(&self, sitemaps: &[&str]) -> String {
220 let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
221 xml.push('\n');
222 xml.push_str(r#"<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">"#);
223 xml.push('\n');
224
225 let now = Utc::now().format("%Y-%m-%d").to_string();
226
227 for sitemap in sitemaps {
228 xml.push_str(" <sitemap>\n");
229 xml.push_str(&format!(
230 " <loc>{}/{}</loc>\n",
231 self.config.base_url(),
232 sitemap
233 ));
234 xml.push_str(&format!(" <lastmod>{now}</lastmod>\n"));
235 xml.push_str(" </sitemap>\n");
236 }
237
238 xml.push_str("</sitemapindex>\n");
239 xml
240 }
241}
242
243fn escape_xml(s: &str) -> String {
245 let mut out = String::with_capacity(s.len());
246 for c in s.chars() {
247 match c {
248 '&' => out.push_str("&"),
249 '<' => out.push_str("<"),
250 '>' => out.push_str(">"),
251 '"' => out.push_str("""),
252 '\'' => out.push_str("'"),
253 _ => out.push(c),
254 }
255 }
256 out
257}
258
259#[must_use]
264pub fn generate_sitemap_xsl() -> String {
265 r#"<?xml version="1.0" encoding="UTF-8"?>
266<xsl:stylesheet version="2.0"
267 xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
268 xmlns:sitemap="http://www.sitemaps.org/schemas/sitemap/0.9"
269 xmlns:xhtml="http://www.w3.org/1999/xhtml">
270
271<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes"/>
272
273<xsl:template match="/">
274<html lang="en">
275<head>
276 <meta charset="UTF-8"/>
277 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
278 <title>Sitemap</title>
279 <style>
280 :root {
281 --bg-primary: #ffffff;
282 --bg-secondary: #f8fafc;
283 --bg-tertiary: #f1f5f9;
284 --text-primary: #0f172a;
285 --text-secondary: #475569;
286 --text-muted: #94a3b8;
287 --border-color: #e2e8f0;
288 --accent-color: #3b82f6;
289 --accent-hover: #2563eb;
290 --priority-high: #22c55e;
291 --priority-medium: #eab308;
292 --priority-low: #94a3b8;
293 }
294
295 @media (prefers-color-scheme: dark) {
296 :root {
297 --bg-primary: #0f172a;
298 --bg-secondary: #1e293b;
299 --bg-tertiary: #334155;
300 --text-primary: #f1f5f9;
301 --text-secondary: #cbd5e1;
302 --text-muted: #64748b;
303 --border-color: #334155;
304 --accent-color: #60a5fa;
305 --accent-hover: #93c5fd;
306 }
307 }
308
309 * {
310 margin: 0;
311 padding: 0;
312 box-sizing: border-box;
313 }
314
315 body {
316 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
317 background-color: var(--bg-primary);
318 color: var(--text-primary);
319 line-height: 1.6;
320 padding: 2rem;
321 }
322
323 .container {
324 max-width: 1200px;
325 margin: 0 auto;
326 }
327
328 header {
329 margin-bottom: 2rem;
330 padding-bottom: 1rem;
331 border-bottom: 1px solid var(--border-color);
332 }
333
334 h1 {
335 font-size: 1.875rem;
336 font-weight: 700;
337 margin-bottom: 0.5rem;
338 }
339
340 .subtitle {
341 color: var(--text-secondary);
342 font-size: 0.875rem;
343 }
344
345 .stats {
346 display: flex;
347 gap: 2rem;
348 margin-top: 1rem;
349 flex-wrap: wrap;
350 }
351
352 .stat {
353 background: var(--bg-secondary);
354 padding: 0.75rem 1.25rem;
355 border-radius: 0.5rem;
356 border: 1px solid var(--border-color);
357 }
358
359 .stat-label {
360 font-size: 0.75rem;
361 text-transform: uppercase;
362 letter-spacing: 0.05em;
363 color: var(--text-muted);
364 }
365
366 .stat-value {
367 font-size: 1.25rem;
368 font-weight: 600;
369 color: var(--accent-color);
370 }
371
372 table {
373 width: 100%;
374 border-collapse: collapse;
375 margin-top: 1.5rem;
376 background: var(--bg-secondary);
377 border-radius: 0.5rem;
378 overflow: hidden;
379 border: 1px solid var(--border-color);
380 }
381
382 thead {
383 background: var(--bg-tertiary);
384 }
385
386 th {
387 padding: 0.875rem 1rem;
388 text-align: left;
389 font-weight: 600;
390 font-size: 0.75rem;
391 text-transform: uppercase;
392 letter-spacing: 0.05em;
393 color: var(--text-secondary);
394 border-bottom: 1px solid var(--border-color);
395 }
396
397 td {
398 padding: 0.875rem 1rem;
399 border-bottom: 1px solid var(--border-color);
400 font-size: 0.875rem;
401 }
402
403 tbody tr:hover {
404 background: var(--bg-tertiary);
405 }
406
407 tbody tr:last-child td {
408 border-bottom: none;
409 }
410
411 a {
412 color: var(--accent-color);
413 text-decoration: none;
414 word-break: break-all;
415 }
416
417 a:hover {
418 color: var(--accent-hover);
419 text-decoration: underline;
420 }
421
422 .priority {
423 display: inline-flex;
424 align-items: center;
425 gap: 0.375rem;
426 }
427
428 .priority-dot {
429 width: 0.5rem;
430 height: 0.5rem;
431 border-radius: 50%;
432 }
433
434 .priority-high .priority-dot {
435 background: var(--priority-high);
436 }
437
438 .priority-medium .priority-dot {
439 background: var(--priority-medium);
440 }
441
442 .priority-low .priority-dot {
443 background: var(--priority-low);
444 }
445
446 .changefreq {
447 display: inline-block;
448 padding: 0.25rem 0.5rem;
449 background: var(--bg-tertiary);
450 border-radius: 0.25rem;
451 font-size: 0.75rem;
452 color: var(--text-secondary);
453 }
454
455 .date {
456 color: var(--text-muted);
457 font-size: 0.8125rem;
458 }
459
460 footer {
461 margin-top: 2rem;
462 padding-top: 1rem;
463 border-top: 1px solid var(--border-color);
464 text-align: center;
465 color: var(--text-muted);
466 font-size: 0.75rem;
467 }
468
469 @media (max-width: 768px) {
470 body {
471 padding: 1rem;
472 }
473
474 .stats {
475 gap: 1rem;
476 }
477
478 th, td {
479 padding: 0.625rem 0.5rem;
480 }
481
482 .hide-mobile {
483 display: none;
484 }
485 }
486 </style>
487</head>
488<body>
489 <div class="container">
490 <header>
491 <h1>🗺️ Sitemap</h1>
492 <p class="subtitle">This sitemap contains all pages available on this website.</p>
493 <div class="stats">
494 <div class="stat">
495 <div class="stat-label">Total URLs</div>
496 <div class="stat-value"><xsl:value-of select="count(sitemap:urlset/sitemap:url)"/></div>
497 </div>
498 </div>
499 </header>
500
501 <table>
502 <thead>
503 <tr>
504 <th>URL</th>
505 <th class="hide-mobile">Priority</th>
506 <th class="hide-mobile">Change Frequency</th>
507 <th class="hide-mobile">Last Modified</th>
508 </tr>
509 </thead>
510 <tbody>
511 <xsl:for-each select="sitemap:urlset/sitemap:url">
512 <xsl:sort select="sitemap:priority" order="descending"/>
513 <tr>
514 <td>
515 <a href="{sitemap:loc}"><xsl:value-of select="sitemap:loc"/></a>
516 </td>
517 <td class="hide-mobile">
518 <xsl:choose>
519 <xsl:when test="sitemap:priority >= 0.8">
520 <span class="priority priority-high">
521 <span class="priority-dot"></span>
522 <xsl:value-of select="sitemap:priority"/>
523 </span>
524 </xsl:when>
525 <xsl:when test="sitemap:priority >= 0.5">
526 <span class="priority priority-medium">
527 <span class="priority-dot"></span>
528 <xsl:value-of select="sitemap:priority"/>
529 </span>
530 </xsl:when>
531 <xsl:otherwise>
532 <span class="priority priority-low">
533 <span class="priority-dot"></span>
534 <xsl:value-of select="sitemap:priority"/>
535 </span>
536 </xsl:otherwise>
537 </xsl:choose>
538 </td>
539 <td class="hide-mobile">
540 <xsl:if test="sitemap:changefreq">
541 <span class="changefreq"><xsl:value-of select="sitemap:changefreq"/></span>
542 </xsl:if>
543 </td>
544 <td class="hide-mobile">
545 <xsl:if test="sitemap:lastmod">
546 <span class="date"><xsl:value-of select="sitemap:lastmod"/></span>
547 </xsl:if>
548 </td>
549 </tr>
550 </xsl:for-each>
551 </tbody>
552 </table>
553
554 <footer>
555 <p>Generated by Typstify • XML Sitemap Protocol</p>
556 </footer>
557 </div>
558</body>
559</html>
560</xsl:template>
561
562</xsl:stylesheet>"#.to_string()
563}
564
565#[cfg(test)]
566mod tests {
567 use std::path::PathBuf;
568
569 use typstify_core::{config::LanguageConfig, test_fixtures::test_config};
570
571 use super::*;
572
573 fn test_page(slug: &str, date: Option<DateTime<Utc>>) -> Page {
574 Page {
575 url: format!("/{}", slug),
576 title: slug.to_string(),
577 description: None,
578 date,
579 updated: None,
580 draft: false,
581 lang: "en".to_string(),
582 is_default_lang: true,
583 canonical_id: slug.to_string(),
584 tags: vec![],
585 categories: vec![],
586 content: String::new(),
587 summary: None,
588 reading_time: None,
589 word_count: None,
590 toc: vec![],
591 custom_js: vec![],
592 custom_css: vec![],
593 aliases: vec![],
594 template: None,
595 weight: 0,
596 source_path: Some(PathBuf::from("test.md")),
597 }
598 }
599
600 #[test]
601 fn test_generate_sitemap() {
602 let config = test_config();
603 let generator = SitemapGenerator::new(&config);
604 let page1 = test_page("about", None);
605 let page2 = test_page("blog/post-1", Some(Utc::now()));
606 let pages: Vec<&Page> = vec![&page1, &page2];
607
608 let xml = generator.generate(&pages).unwrap();
609
610 assert!(xml.contains(r#"<?xml version="1.0""#));
611 assert!(xml.contains("<urlset"));
612 assert!(xml.contains("<loc>https://example.com/about</loc>"));
613 assert!(xml.contains("<loc>https://example.com/blog/post-1</loc>"));
614 assert!(xml.contains("<changefreq>"));
615 assert!(xml.contains("<priority>"));
616 }
617
618 #[test]
619 fn test_escape_xml() {
620 assert_eq!(escape_xml("a & b"), "a & b");
621 assert_eq!(escape_xml("<tag>"), "<tag>");
622 assert_eq!(escape_xml("\"quoted\""), ""quoted"");
623 assert_eq!(escape_xml("'apostrophe'"), "'apostrophe'");
624 assert_eq!(
625 escape_xml("a & < b > \"c\" 'd'"),
626 "a & < b > "c" 'd'"
627 );
628 assert_eq!(escape_xml("no special"), "no special");
629 assert_eq!(escape_xml(""), "");
630 }
631
632 #[test]
633 fn test_home_page_priority() {
634 let config = test_config();
635 let generator = SitemapGenerator::new(&config);
636 let mut home = test_page("", None);
637 home.url = "/".to_string();
638
639 let url = generator.page_to_url(&home);
640
641 assert_eq!(url.priority, Some(1.0));
642 assert_eq!(url.changefreq, Some(ChangeFreq::Daily));
643 }
644
645 #[test]
646 fn test_generate_index() {
647 let config = test_config();
648 let generator = SitemapGenerator::new(&config);
649 let sitemaps = vec!["sitemap-posts.xml", "sitemap-pages.xml"];
650
651 let xml = generator.generate_index(&sitemaps);
652
653 assert!(xml.contains("<sitemapindex"));
654 assert!(xml.contains("sitemap-posts.xml"));
655 assert!(xml.contains("sitemap-pages.xml"));
656 }
657
658 #[test]
659 fn test_multilang_sitemap() {
660 let mut config = test_config();
661 config.languages.insert(
662 "en".to_string(),
663 LanguageConfig {
664 name: Some("English".to_string()),
665 title: None,
666 description: None,
667 },
668 );
669 config.languages.insert(
670 "zh".to_string(),
671 LanguageConfig {
672 name: Some("中文".to_string()),
673 title: None,
674 description: None,
675 },
676 );
677 let generator = SitemapGenerator::new(&config);
678
679 let page = test_page("about", None);
680 let pages: Vec<&Page> = vec![&page];
681
682 let xml = generator.generate(&pages).unwrap();
683
684 assert!(xml.contains("xmlns:xhtml"));
685 assert!(xml.contains(r#"hreflang="en""#));
686 assert!(xml.contains(r#"hreflang="zh""#));
687 }
688}