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