1use std::fs;
39use std::io;
40use std::path::{Path, PathBuf};
41
42#[derive(Debug, Clone)]
47pub struct Meta {
48 title: String,
49 description: String,
50 canonical: Option<String>,
51 image: Option<String>,
52}
53
54impl Meta {
55 pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
58 Self {
59 title: title.into(),
60 description: description.into(),
61 canonical: None,
62 image: None,
63 }
64 }
65
66 pub fn canonical(mut self, url: impl Into<String>) -> Self {
68 self.canonical = Some(url.into());
69 self
70 }
71
72 pub fn image(mut self, url: impl Into<String>) -> Self {
74 self.image = Some(url.into());
75 self
76 }
77
78 pub fn head_tags(&self) -> String {
82 let title = escape(&self.title);
83 let description = escape(&self.description);
84 let mut out = format!(
85 "<title>{title}</title>\n\
86 <meta name=\"description\" content=\"{description}\">\n\
87 <meta property=\"og:title\" content=\"{title}\">\n\
88 <meta property=\"og:description\" content=\"{description}\">\n\
89 <meta property=\"og:type\" content=\"website\">\n\
90 <meta name=\"twitter:card\" content=\"summary_large_image\">"
91 );
92 if let Some(url) = &self.canonical {
93 let url = escape(url);
94 out.push_str(&format!(
95 "\n<link rel=\"canonical\" href=\"{url}\">\n\
96 <meta property=\"og:url\" content=\"{url}\">"
97 ));
98 }
99 if let Some(image) = &self.image {
100 let image = escape(image);
101 out.push_str(&format!(
102 "\n<meta property=\"og:image\" content=\"{image}\">"
103 ));
104 }
105 out
106 }
107}
108
109pub struct StaticSite {
113 out: PathBuf,
114 base_url: String,
115 paths: Vec<String>,
116}
117
118impl StaticSite {
119 pub fn new(out_dir: impl Into<PathBuf>, base_url: impl Into<String>) -> io::Result<Self> {
123 let out = out_dir.into();
124 fs::create_dir_all(&out)?;
125 Ok(Self {
126 out,
127 base_url: base_url.into().trim_end_matches('/').to_string(),
128 paths: Vec::new(),
129 })
130 }
131
132 pub fn page(&mut self, path: &str, html: &str) -> io::Result<&mut Self> {
137 let file = self.out.join(path_to_file(path));
138 if let Some(parent) = file.parent() {
139 fs::create_dir_all(parent)?;
140 }
141 fs::write(file, html)?;
142 self.paths.push(normalise_path(path));
143 Ok(self)
144 }
145
146 pub fn assets(&self, from: impl AsRef<Path>, to: &str) -> io::Result<()> {
149 copy_dir(from.as_ref(), &self.out.join(to))
150 }
151
152 pub fn file(&self, path: &str, contents: &str) -> io::Result<()> {
162 let relative = path.trim_start_matches('/');
163 if relative.split('/').any(|part| part == "..") {
164 return Err(io::Error::new(
165 io::ErrorKind::InvalidInput,
166 format!("`{path}` climbs out of the output directory"),
167 ));
168 }
169 let target = self.out.join(relative);
170 if let Some(parent) = target.parent() {
171 fs::create_dir_all(parent)?;
172 }
173 fs::write(target, contents)
174 }
175
176 pub fn base_url(&self) -> &str {
180 &self.base_url
181 }
182
183 pub fn paths(&self) -> &[String] {
189 &self.paths
190 }
191}
192
193fn path_to_file(path: &str) -> PathBuf {
196 let trimmed = path.trim_matches('/');
197 if trimmed.is_empty() {
198 PathBuf::from("index.html")
199 } else {
200 PathBuf::from(trimmed).join("index.html")
201 }
202}
203
204fn normalise_path(path: &str) -> String {
207 let trimmed = path.trim_matches('/');
208 if trimmed.is_empty() {
209 "/".to_string()
210 } else {
211 format!("/{trimmed}/")
212 }
213}
214
215fn escape(s: &str) -> String {
217 s.replace('&', "&")
218 .replace('<', "<")
219 .replace('>', ">")
220 .replace('"', """)
221}
222
223fn copy_dir(from: &Path, to: &Path) -> io::Result<()> {
224 fs::create_dir_all(to)?;
225 for entry in fs::read_dir(from)? {
226 let entry = entry?;
227 let src = entry.path();
228 let dst = to.join(entry.file_name());
229 if entry.file_type()?.is_dir() {
230 copy_dir(&src, &dst)?;
231 } else {
232 fs::copy(&src, &dst)?;
233 }
234 }
235 Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn paths_map_to_clean_url_files() {
244 assert_eq!(path_to_file("/"), PathBuf::from("index.html"));
245 assert_eq!(path_to_file(""), PathBuf::from("index.html"));
246 assert_eq!(
247 path_to_file("/features/"),
248 PathBuf::from("features/index.html")
249 );
250 assert_eq!(
251 path_to_file("/get-started"),
252 PathBuf::from("get-started/index.html")
253 );
254 }
255
256 #[test]
257 fn head_tags_include_and_escape_metadata() {
258 let meta = Meta::new("Acme & Co", "A \"great\" site")
259 .canonical("https://acme.example/")
260 .image("https://acme.example/card.png");
261 let tags = meta.head_tags();
262 assert!(tags.contains("<title>Acme & Co</title>"));
263 assert!(tags.contains("content=\"A "great" site\""));
264 assert!(tags.contains("rel=\"canonical\" href=\"https://acme.example/\""));
265 assert!(tags.contains("og:image"));
266 }
267
268 #[test]
269 fn builds_pages_and_copies_assets() {
270 let tmp = tempfile::tempdir().unwrap();
271 let assets = tmp.path().join("static");
272 fs::create_dir_all(&assets).unwrap();
273 fs::write(assets.join("site.css"), "body{}").unwrap();
274
275 let out = tmp.path().join("dist");
276 let mut site = StaticSite::new(&out, "https://acme.example/").unwrap();
277 site.page("/", "<h1>Home</h1>").unwrap();
278 site.page("/features/", "<h1>Features</h1>").unwrap();
279 site.assets(&assets, "static").unwrap();
280
281 assert_eq!(
282 fs::read_to_string(out.join("index.html")).unwrap(),
283 "<h1>Home</h1>"
284 );
285 assert!(out.join("features/index.html").exists());
286 assert!(out.join("static/site.css").exists());
287
288 assert!(!out.join("sitemap.xml").exists());
291 assert!(!out.join("robots.txt").exists());
292
293 assert_eq!(site.paths(), ["/", "/features/"]);
296 assert_eq!(site.base_url(), "https://acme.example");
297 }
298
299 #[test]
300 fn a_site_writes_its_own_root_files() {
301 let tmp = tempfile::tempdir().unwrap();
302 let out = tmp.path().join("dist");
303 let site = StaticSite::new(&out, "https://acme.example").unwrap();
304
305 site.file("robots.txt", "User-agent: *\nDisallow: /private/\n")
306 .unwrap();
307 site.file(
309 "/.well-known/security.txt",
310 "Contact: mailto:x@acme.example\n",
311 )
312 .unwrap();
313
314 let robots = fs::read_to_string(out.join("robots.txt")).unwrap();
315 assert!(robots.contains("Disallow: /private/"));
316 assert!(out.join(".well-known/security.txt").exists());
317
318 assert!(site.paths().is_empty());
320 }
321
322 #[test]
323 fn a_root_file_cannot_escape_the_output_directory() {
324 let tmp = tempfile::tempdir().unwrap();
325 let out = tmp.path().join("dist");
326 let site = StaticSite::new(&out, "https://acme.example").unwrap();
327 assert!(site.file("../escaped.txt", "no").is_err());
328 assert!(site.file("a/../../escaped.txt", "no").is_err());
329 assert!(!tmp.path().join("escaped.txt").exists());
330 }
331}