Skip to main content

laterite_web/
lib.rs

1//! The Laterite public web layer.
2//!
3//! Where [`laterite_admin`](https://docs.rs/laterite-admin) renders the private
4//! admin, this crate renders the public face of an application. Its first
5//! capability is **static-site generation**: an application renders its pages to
6//! HTML strings and hands them to a [`StaticSite`], which writes them as files,
7//! copies static assets, and generates a `sitemap.xml` and `robots.txt`. The
8//! output is a plain directory suitable for any static host or CDN.
9//!
10//! Page templates live in the application (Askama, or any renderer that produces
11//! a `String`); this crate owns the file layout, the sitemap, and the shared
12//! [`Meta`] tags (title, description, canonical URL, Open Graph) so pages stay
13//! consistent and shareable.
14//!
15//! ```no_run
16//! use laterite_web::{Meta, StaticSite};
17//!
18//! # fn render_home(head: &str) -> String { String::new() }
19//! # fn main() -> std::io::Result<()> {
20//! let meta = Meta::new("Acme", "The Acme website.").canonical("https://acme.example/");
21//! let home = render_home(&meta.head_tags());
22//!
23//! let mut site = StaticSite::new("dist", "https://acme.example")?;
24//! site.page("/", &home)?;
25//! site.assets("static", "static")?;
26//! site.finish()?;
27//! # Ok(()) }
28//! ```
29
30use std::fs;
31use std::io;
32use std::path::{Path, PathBuf};
33
34/// Page metadata rendered into a document's `<head>`: the title, description,
35/// optional canonical URL, and optional social-share image. [`Meta::head_tags`]
36/// renders the standard `<title>`, `<meta>`, and Open Graph tags so every page is
37/// described and shareable the same way.
38#[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    /// A page's title and description. Both appear in search results and social
48    /// cards, so keep them human and specific.
49    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    /// The canonical absolute URL for this page (also used as the Open Graph URL).
59    pub fn canonical(mut self, url: impl Into<String>) -> Self {
60        self.canonical = Some(url.into());
61        self
62    }
63
64    /// The absolute URL of the social-share image (Open Graph / Twitter card).
65    pub fn image(mut self, url: impl Into<String>) -> Self {
66        self.image = Some(url.into());
67        self
68    }
69
70    /// Renders the `<head>` tags for this page: title, description, canonical
71    /// link, and Open Graph / Twitter card metadata. Embed the result inside the
72    /// document `<head>`. All values are HTML-attribute escaped.
73    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
101/// A static site being assembled into an output directory. Pages are written as
102/// `index.html` under a directory per path (so URLs are clean and extensionless),
103/// static assets are copied verbatim, and [`StaticSite::finish`] writes a
104/// `sitemap.xml` and `robots.txt` covering every page added.
105pub struct StaticSite {
106    out: PathBuf,
107    base_url: String,
108    paths: Vec<String>,
109}
110
111impl StaticSite {
112    /// Prepares an output directory and records the site's base URL (used for the
113    /// sitemap and robots entries), for example `https://acme.example`.
114    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    /// Writes a page's rendered `html` for the URL `path`. The path maps to a
125    /// clean-URL file: `/` becomes `index.html`, `/features/` becomes
126    /// `features/index.html`. The page is recorded for the sitemap.
127    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    /// Copies a directory of static assets (CSS, fonts, images) from `from` into
138    /// the output at `to`, recursively.
139    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    /// Writes `sitemap.xml` and `robots.txt` covering every page added so far.
144    /// Call once, after all pages are written.
145    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
172/// Maps a URL path to its output file: `/` and `""` to `index.html`, and any
173/// other path to `<path>/index.html` for clean, extensionless URLs.
174fn 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
183/// Normalises a URL path for the sitemap: leading slash, trailing slash for
184/// directories, and `/` for the root.
185fn 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
194/// Escapes a string for use in HTML text or a double-quoted attribute.
195fn escape(s: &str) -> String {
196    s.replace('&', "&amp;")
197        .replace('<', "&lt;")
198        .replace('>', "&gt;")
199        .replace('"', "&quot;")
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 &amp; Co</title>"));
242        assert!(tags.contains("content=\"A &quot;great&quot; 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}