ssg/plugins/seo/
robots.rs1use crate::error::{PathErrorExt, SsgError};
7use crate::plugin::{Plugin, PluginContext};
8use std::fs;
9
10#[derive(Debug, Clone)]
26pub struct RobotsPlugin {
27 base_url: String,
28}
29
30impl RobotsPlugin {
31 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 assert_eq!(RobotsPlugin::new("https://x.example").name(), "robots");
96 }
97
98 #[test]
99 fn new_accepts_string_or_str() {
100 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 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 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 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}