Skip to main content

ssg/plugins/seo/
robots.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! robots.txt generation plugin.
5
6use crate::error::{PathErrorExt, SsgError};
7use crate::plugin::{Plugin, PluginContext};
8use std::fs;
9
10/// Generates a `robots.txt` file in the site directory.
11///
12/// The file allows all user agents and references the sitemap at
13/// `{base_url}/sitemap.xml`. If a `robots.txt` already exists, it is
14/// not overwritten.
15///
16/// # Example
17///
18/// ```rust
19/// use ssg::plugin::PluginManager;
20/// use ssg::seo::RobotsPlugin;
21///
22/// let mut pm = PluginManager::new();
23/// pm.register(RobotsPlugin::new("https://example.com"));
24/// ```
25#[derive(Debug, Clone)]
26pub struct RobotsPlugin {
27    base_url: String,
28}
29
30impl RobotsPlugin {
31    /// Creates a new `RobotsPlugin` with the given base URL.
32    ///
33    /// # Examples
34    ///
35    /// ```rust
36    /// use ssg::seo::RobotsPlugin;
37    /// use ssg::plugin::Plugin;
38    ///
39    /// let p = RobotsPlugin::new("https://example.com");
40    /// assert_eq!(p.name(), "robots");
41    /// ```
42    pub fn new(base_url: impl Into<String>) -> Self {
43        Self {
44            base_url: base_url.into(),
45        }
46    }
47}
48
49impl Plugin for RobotsPlugin {
50    fn name(&self) -> &'static str {
51        "robots"
52    }
53
54    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
55        if !ctx.site_dir.exists() {
56            return Ok(());
57        }
58
59        let robots_path = ctx.site_dir.join("robots.txt");
60        if robots_path.exists() {
61            return Ok(());
62        }
63
64        let content = format!(
65            "User-agent: *\nAllow: /\nSitemap: {}/sitemap.xml\n",
66            self.base_url.trim_end_matches('/')
67        );
68
69        fs::write(&robots_path, content).with_path(&robots_path)?;
70
71        Ok(())
72    }
73}
74
75#[cfg(test)]
76mod tests {
77
78    use super::*;
79    use std::path::Path;
80    use tempfile::tempdir;
81
82    fn ctx(site: &Path) -> PluginContext {
83        PluginContext::new(
84            Path::new("content"),
85            Path::new("build"),
86            site,
87            Path::new("templates"),
88        )
89    }
90
91    #[test]
92    fn name_is_stable() {
93        // Plugin name is part of the public contract — log lines and
94        // PluginManager APIs key off it. Pin the value.
95        assert_eq!(RobotsPlugin::new("https://x.example").name(), "robots");
96    }
97
98    #[test]
99    fn new_accepts_string_or_str() {
100        // Both `&str` and `String` should work via `impl Into<String>`.
101        let _from_str = RobotsPlugin::new("https://a.example");
102        let _from_string = RobotsPlugin::new(String::from("https://b.example"));
103    }
104
105    #[test]
106    fn writes_robots_txt_when_missing() {
107        let dir = tempdir().unwrap();
108        let plugin = RobotsPlugin::new("https://example.com");
109        plugin.after_compile(&ctx(dir.path())).unwrap();
110
111        let body = fs::read_to_string(dir.path().join("robots.txt")).unwrap();
112        assert_eq!(
113            body,
114            "User-agent: *\nAllow: /\nSitemap: https://example.com/sitemap.xml\n"
115        );
116    }
117
118    #[test]
119    fn trims_trailing_slash_from_base_url() {
120        let dir = tempdir().unwrap();
121        let plugin = RobotsPlugin::new("https://example.com/");
122        plugin.after_compile(&ctx(dir.path())).unwrap();
123
124        let body = fs::read_to_string(dir.path().join("robots.txt")).unwrap();
125        assert!(
126            body.contains("Sitemap: https://example.com/sitemap.xml\n"),
127            "trailing slash on base_url should be trimmed before joining \
128             /sitemap.xml, got: {body}"
129        );
130        assert!(
131            !body.contains("//sitemap.xml"),
132            "should not produce double-slash"
133        );
134    }
135
136    #[test]
137    fn does_not_overwrite_existing_robots_txt() {
138        let dir = tempdir().unwrap();
139        let custom = "User-agent: GPTBot\nDisallow: /\n";
140        fs::write(dir.path().join("robots.txt"), custom).unwrap();
141
142        let plugin = RobotsPlugin::new("https://example.com");
143        plugin.after_compile(&ctx(dir.path())).unwrap();
144
145        let body = fs::read_to_string(dir.path().join("robots.txt")).unwrap();
146        assert_eq!(body, custom, "existing robots.txt must be left untouched");
147    }
148
149    #[test]
150    fn no_op_when_site_dir_missing() {
151        // Site dir doesn't exist — plugin must succeed silently.
152        let dir = tempdir().unwrap();
153        let nonexistent = dir.path().join("nope");
154        let plugin = RobotsPlugin::new("https://example.com");
155        plugin.after_compile(&ctx(&nonexistent)).unwrap();
156        assert!(
157            !nonexistent.join("robots.txt").exists(),
158            "plugin should not create files in a missing site dir"
159        );
160    }
161
162    #[test]
163    fn robots_txt_with_custom_sitemap_url() {
164        let dir = tempdir().unwrap();
165        let plugin = RobotsPlugin::new("https://blog.example.org");
166        plugin.after_compile(&ctx(dir.path())).unwrap();
167
168        let body = fs::read_to_string(dir.path().join("robots.txt")).unwrap();
169        assert!(
170            body.contains("Sitemap: https://blog.example.org/sitemap.xml"),
171            "sitemap URL should use the custom base_url: {body}"
172        );
173    }
174
175    #[cfg(unix)]
176    #[test]
177    fn write_failure_is_reported_as_io_error() {
178        // Site dir exists but is read-only, so the `fs::write` at the
179        // end of `after_compile` fails and the error is surfaced with
180        // the robots.txt path attached.
181        use std::os::unix::fs::PermissionsExt;
182
183        let dir = tempdir().unwrap();
184        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o555))
185            .unwrap();
186
187        let plugin = RobotsPlugin::new("https://example.com");
188        let err = plugin
189            .after_compile(&ctx(dir.path()))
190            .expect_err("write into a read-only site dir must fail");
191        assert!(
192            err.to_string().contains("robots.txt"),
193            "error should carry the robots.txt path, got: {err}"
194        );
195
196        // Restore permissions so the tempdir can be cleaned up.
197        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755))
198            .unwrap();
199    }
200
201    #[test]
202    fn robots_txt_preserves_existing_disallow() {
203        let dir = tempdir().unwrap();
204        let custom = "User-agent: *\nDisallow: /admin/\nDisallow: /private/\n";
205        fs::write(dir.path().join("robots.txt"), custom).unwrap();
206
207        let plugin = RobotsPlugin::new("https://example.com");
208        plugin.after_compile(&ctx(dir.path())).unwrap();
209
210        let body = fs::read_to_string(dir.path().join("robots.txt")).unwrap();
211        assert_eq!(
212            body, custom,
213            "existing robots.txt with disallow rules must not be overwritten"
214        );
215        assert!(body.contains("Disallow: /admin/"));
216        assert!(body.contains("Disallow: /private/"));
217    }
218}