1use std::fs;
31use std::io;
32use std::path::{Path, PathBuf};
33
34#[derive(Debug, Clone)]
39pub struct Meta {
40 title: String,
41 description: String,
42 canonical: Option<String>,
43 image: Option<String>,
44}
45
46impl Meta {
47 pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
50 Self {
51 title: title.into(),
52 description: description.into(),
53 canonical: None,
54 image: None,
55 }
56 }
57
58 pub fn canonical(mut self, url: impl Into<String>) -> Self {
60 self.canonical = Some(url.into());
61 self
62 }
63
64 pub fn image(mut self, url: impl Into<String>) -> Self {
66 self.image = Some(url.into());
67 self
68 }
69
70 pub fn head_tags(&self) -> String {
74 let title = escape(&self.title);
75 let description = escape(&self.description);
76 let mut out = format!(
77 "<title>{title}</title>\n\
78 <meta name=\"description\" content=\"{description}\">\n\
79 <meta property=\"og:title\" content=\"{title}\">\n\
80 <meta property=\"og:description\" content=\"{description}\">\n\
81 <meta property=\"og:type\" content=\"website\">\n\
82 <meta name=\"twitter:card\" content=\"summary_large_image\">"
83 );
84 if let Some(url) = &self.canonical {
85 let url = escape(url);
86 out.push_str(&format!(
87 "\n<link rel=\"canonical\" href=\"{url}\">\n\
88 <meta property=\"og:url\" content=\"{url}\">"
89 ));
90 }
91 if let Some(image) = &self.image {
92 let image = escape(image);
93 out.push_str(&format!(
94 "\n<meta property=\"og:image\" content=\"{image}\">"
95 ));
96 }
97 out
98 }
99}
100
101pub struct StaticSite {
106 out: PathBuf,
107 base_url: String,
108 paths: Vec<String>,
109}
110
111impl StaticSite {
112 pub fn new(out_dir: impl Into<PathBuf>, base_url: impl Into<String>) -> io::Result<Self> {
115 let out = out_dir.into();
116 fs::create_dir_all(&out)?;
117 Ok(Self {
118 out,
119 base_url: base_url.into().trim_end_matches('/').to_string(),
120 paths: Vec::new(),
121 })
122 }
123
124 pub fn page(&mut self, path: &str, html: &str) -> io::Result<&mut Self> {
128 let file = self.out.join(path_to_file(path));
129 if let Some(parent) = file.parent() {
130 fs::create_dir_all(parent)?;
131 }
132 fs::write(file, html)?;
133 self.paths.push(normalise_path(path));
134 Ok(self)
135 }
136
137 pub fn assets(&self, from: impl AsRef<Path>, to: &str) -> io::Result<()> {
140 copy_dir(from.as_ref(), &self.out.join(to))
141 }
142
143 pub fn finish(&self) -> io::Result<()> {
146 fs::write(self.out.join("sitemap.xml"), self.sitemap())?;
147 fs::write(self.out.join("robots.txt"), self.robots())?;
148 Ok(())
149 }
150
151 fn sitemap(&self) -> String {
152 let mut xml = String::from(
153 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
154 <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
155 );
156 for path in &self.paths {
157 let loc = escape(&format!("{}{}", self.base_url, path));
158 xml.push_str(&format!(" <url><loc>{loc}</loc></url>\n"));
159 }
160 xml.push_str("</urlset>\n");
161 xml
162 }
163
164 fn robots(&self) -> String {
165 format!(
166 "User-agent: *\nAllow: /\n\nSitemap: {}/sitemap.xml\n",
167 self.base_url
168 )
169 }
170}
171
172fn path_to_file(path: &str) -> PathBuf {
175 let trimmed = path.trim_matches('/');
176 if trimmed.is_empty() {
177 PathBuf::from("index.html")
178 } else {
179 PathBuf::from(trimmed).join("index.html")
180 }
181}
182
183fn normalise_path(path: &str) -> String {
186 let trimmed = path.trim_matches('/');
187 if trimmed.is_empty() {
188 "/".to_string()
189 } else {
190 format!("/{trimmed}/")
191 }
192}
193
194fn escape(s: &str) -> String {
196 s.replace('&', "&")
197 .replace('<', "<")
198 .replace('>', ">")
199 .replace('"', """)
200}
201
202fn copy_dir(from: &Path, to: &Path) -> io::Result<()> {
203 fs::create_dir_all(to)?;
204 for entry in fs::read_dir(from)? {
205 let entry = entry?;
206 let src = entry.path();
207 let dst = to.join(entry.file_name());
208 if entry.file_type()?.is_dir() {
209 copy_dir(&src, &dst)?;
210 } else {
211 fs::copy(&src, &dst)?;
212 }
213 }
214 Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn paths_map_to_clean_url_files() {
223 assert_eq!(path_to_file("/"), PathBuf::from("index.html"));
224 assert_eq!(path_to_file(""), PathBuf::from("index.html"));
225 assert_eq!(
226 path_to_file("/features/"),
227 PathBuf::from("features/index.html")
228 );
229 assert_eq!(
230 path_to_file("/get-started"),
231 PathBuf::from("get-started/index.html")
232 );
233 }
234
235 #[test]
236 fn head_tags_include_and_escape_metadata() {
237 let meta = Meta::new("Acme & Co", "A \"great\" site")
238 .canonical("https://acme.example/")
239 .image("https://acme.example/card.png");
240 let tags = meta.head_tags();
241 assert!(tags.contains("<title>Acme & Co</title>"));
242 assert!(tags.contains("content=\"A "great" site\""));
243 assert!(tags.contains("rel=\"canonical\" href=\"https://acme.example/\""));
244 assert!(tags.contains("og:image"));
245 }
246
247 #[test]
248 fn builds_pages_assets_sitemap_and_robots() {
249 let tmp = tempfile::tempdir().unwrap();
250 let assets = tmp.path().join("static");
251 fs::create_dir_all(&assets).unwrap();
252 fs::write(assets.join("site.css"), "body{}").unwrap();
253
254 let out = tmp.path().join("dist");
255 let mut site = StaticSite::new(&out, "https://acme.example/").unwrap();
256 site.page("/", "<h1>Home</h1>").unwrap();
257 site.page("/features/", "<h1>Features</h1>").unwrap();
258 site.assets(&assets, "static").unwrap();
259 site.finish().unwrap();
260
261 assert_eq!(
262 fs::read_to_string(out.join("index.html")).unwrap(),
263 "<h1>Home</h1>"
264 );
265 assert!(out.join("features/index.html").exists());
266 assert!(out.join("static/site.css").exists());
267
268 let sitemap = fs::read_to_string(out.join("sitemap.xml")).unwrap();
269 assert!(sitemap.contains("<loc>https://acme.example/</loc>"));
270 assert!(sitemap.contains("<loc>https://acme.example/features/</loc>"));
271
272 let robots = fs::read_to_string(out.join("robots.txt")).unwrap();
273 assert!(robots.contains("Sitemap: https://acme.example/sitemap.xml"));
274 }
275}