Skip to main content

typstify_generator/
sitemap.rs

1//! Sitemap generation.
2//!
3//! Generates XML sitemaps for search engine optimization.
4
5use std::io::Write;
6
7use chrono::{DateTime, Utc};
8use thiserror::Error;
9use tracing::debug;
10use typstify_core::{Config, Page};
11
12/// Sitemap generation errors.
13#[derive(Debug, Error)]
14pub enum SitemapError {
15    /// IO error.
16    #[error("IO error: {0}")]
17    Io(#[from] std::io::Error),
18
19    /// XML encoding error.
20    #[error("XML encoding error: {0}")]
21    Xml(String),
22}
23
24/// Result type for sitemap operations.
25pub type Result<T> = std::result::Result<T, SitemapError>;
26
27/// Change frequency for sitemap entries.
28#[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/// A sitemap URL entry.
54#[derive(Debug, Clone)]
55pub struct SitemapUrl {
56    /// URL location.
57    pub loc: String,
58
59    /// Last modification date.
60    pub lastmod: Option<DateTime<Utc>>,
61
62    /// Change frequency.
63    pub changefreq: Option<ChangeFreq>,
64
65    /// Priority (0.0 to 1.0).
66    pub priority: Option<f32>,
67
68    /// Alternate language versions.
69    pub alternates: Vec<AlternateLink>,
70}
71
72/// Alternate language link for a URL.
73#[derive(Debug, Clone)]
74pub struct AlternateLink {
75    /// Language code (e.g., "en", "zh").
76    pub hreflang: String,
77
78    /// URL for this language version.
79    pub href: String,
80}
81
82/// Sitemap generator.
83#[derive(Debug)]
84pub struct SitemapGenerator<'a> {
85    config: &'a Config,
86}
87
88impl<'a> SitemapGenerator<'a> {
89    /// Create a new sitemap generator.
90    #[must_use]
91    pub fn new(config: &'a Config) -> Self {
92        Self { config }
93    }
94
95    /// Generate sitemap XML from pages.
96    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        // Add XSLT stylesheet reference for browser rendering
102        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        // Add xhtml namespace if we have multiple languages
107        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    /// Convert a page to a sitemap URL entry.
124    fn page_to_url(&self, page: &Page) -> SitemapUrl {
125        let loc = format!("{}{}", self.config.base_url(), page.url);
126
127        // Determine lastmod from page date or updated date
128        let lastmod = page.updated.or(page.date);
129
130        // Determine change frequency and priority based on content type
131        let (changefreq, priority) = if page.url == "/" || page.url.is_empty() {
132            // Home page
133            (Some(ChangeFreq::Daily), Some(1.0))
134        } else if page.date.is_some() {
135            // Blog posts
136            (Some(ChangeFreq::Monthly), Some(0.8))
137        } else {
138            // Static pages
139            (Some(ChangeFreq::Yearly), Some(0.5))
140        };
141
142        // Build alternate links for multi-language sites
143        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    /// Convert a URL entry to XML.
174    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        // Add alternate language links
198        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    /// Write sitemap to a writer.
212    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    /// Generate sitemap index for multiple sitemaps.
219    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
243/// Escape special XML characters.
244fn 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("&amp;"),
249            '<' => out.push_str("&lt;"),
250            '>' => out.push_str("&gt;"),
251            '"' => out.push_str("&quot;"),
252            '\'' => out.push_str("&apos;"),
253            _ => out.push(c),
254        }
255    }
256    out
257}
258
259/// Generate XSLT stylesheet for sitemap rendering in browsers.
260///
261/// This creates a modern, clean stylesheet with light/dark mode support
262/// that renders the sitemap as an HTML table.
263#[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 &gt;= 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 &gt;= 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 &amp; b");
621        assert_eq!(escape_xml("<tag>"), "&lt;tag&gt;");
622        assert_eq!(escape_xml("\"quoted\""), "&quot;quoted&quot;");
623        assert_eq!(escape_xml("'apostrophe'"), "&apos;apostrophe&apos;");
624        assert_eq!(
625            escape_xml("a & < b > \"c\" 'd'"),
626            "a &amp; &lt; b &gt; &quot;c&quot; &apos;d&apos;"
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}