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//! and copies static assets. The output is a plain directory suitable for any
8//! static host or CDN.
9//!
10//! It writes **no `robots.txt` and no `sitemap.xml`**. How a site wants to be
11//! crawled, and which of its URLs it advertises at what priority, are the
12//! application's decisions, not the framework's. What this crate offers is the
13//! material for them: [`StaticSite::paths`] reports every page written, and
14//! [`StaticSite::file`] writes whatever the site decides to publish.
15//!
16//! Page templates live in the application (Askama, or any renderer that produces
17//! a `String`); this crate owns the file layout, the sitemap, and the shared
18//! [`Meta`] tags (title, description, canonical URL, Open Graph) so pages stay
19//! consistent and shareable.
20//!
21//! ```no_run
22//! use laterite_web::{Meta, StaticSite};
23//!
24//! # fn render_home(head: &str) -> String { String::new() }
25//! # fn main() -> std::io::Result<()> {
26//! let meta = Meta::new("Acme", "The Acme website.").canonical("https://acme.example/");
27//! let home = render_home(&meta.head_tags());
28//!
29//! let mut site = StaticSite::new("dist", "https://acme.example")?;
30//! site.page("/", &home)?;
31//! site.assets("static", "static")?;
32//!
33//! // What the site publishes about itself is its own decision.
34//! site.file("robots.txt", "User-agent: *\nAllow: /\n")?;
35//! # Ok(()) }
36//! ```
37
38use std::fs;
39use std::io;
40use std::path::{Path, PathBuf};
41
42/// Page metadata rendered into a document's `<head>`: the title, description,
43/// optional canonical URL, and optional social-share image. [`Meta::head_tags`]
44/// renders the standard `<title>`, `<meta>`, and Open Graph tags so every page is
45/// described and shareable the same way.
46#[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    /// A page's title and description. Both appear in search results and social
56    /// cards, so keep them human and specific.
57    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    /// The canonical absolute URL for this page (also used as the Open Graph URL).
67    pub fn canonical(mut self, url: impl Into<String>) -> Self {
68        self.canonical = Some(url.into());
69        self
70    }
71
72    /// The absolute URL of the social-share image (Open Graph / Twitter card).
73    pub fn image(mut self, url: impl Into<String>) -> Self {
74        self.image = Some(url.into());
75        self
76    }
77
78    /// Renders the `<head>` tags for this page: title, description, canonical
79    /// link, and Open Graph / Twitter card metadata. Embed the result inside the
80    /// document `<head>`. All values are HTML-attribute escaped.
81    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
109/// A static site being assembled into an output directory. Pages are written as
110/// `index.html` under a directory per path (so URLs are clean and extensionless),
111/// and static assets are copied verbatim.
112pub struct StaticSite {
113    out: PathBuf,
114    base_url: String,
115    paths: Vec<String>,
116}
117
118impl StaticSite {
119    /// Prepares an output directory and records the site's base URL, for example
120    /// `https://acme.example`. The base URL is what [`Meta::canonical`] and any
121    /// absolute link the site builds are formed against.
122    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    /// Writes a page's rendered `html` for the URL `path`. The path maps to a
133    /// clean-URL file: `/` becomes `index.html`, `/features/` becomes
134    /// `features/index.html`. The path is recorded, and [`paths`](Self::paths)
135    /// reports it.
136    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    /// Copies a directory of static assets (CSS, fonts, images) from `from` into
147    /// the output at `to`, recursively.
148    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    /// Writes a file verbatim at `path`, relative to the output root.
153    ///
154    /// For the files a site decides for itself and a generator should not invent:
155    /// `robots.txt`, `humans.txt`, `llms.txt`, a `CNAME`, a verification token.
156    /// Unlike [`page`](Self::page) the contents are written as given, with no
157    /// clean-URL mapping, and the path is not added to the sitemap.
158    ///
159    /// The path is confined to the output directory: a leading slash is trimmed
160    /// and a `..` segment is refused, so a caller cannot write outside `dist`.
161    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    /// The site's base URL, without a trailing slash. Paired with
177    /// [`paths`](Self::paths) it gives the absolute URL of every page written,
178    /// which is what a sitemap is built from.
179    pub fn base_url(&self) -> &str {
180        &self.base_url
181    }
182
183    /// Every page path written so far, normalised, in the order they were added.
184    ///
185    /// This is the material a sitemap is built from, without this crate deciding
186    /// what the sitemap says. Which URLs a site advertises, at what priority and
187    /// change frequency, is the site's own policy.
188    pub fn paths(&self) -> &[String] {
189        &self.paths
190    }
191}
192
193/// Maps a URL path to its output file: `/` and `""` to `index.html`, and any
194/// other path to `<path>/index.html` for clean, extensionless URLs.
195fn 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
204/// Normalises a URL path for the sitemap: leading slash, trailing slash for
205/// directories, and `/` for the root.
206fn 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
215/// Escapes a string for use in HTML text or a double-quoted attribute.
216fn escape(s: &str) -> String {
217    s.replace('&', "&amp;")
218        .replace('<', "&lt;")
219        .replace('>', "&gt;")
220        .replace('"', "&quot;")
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 &amp; Co</title>"));
263        assert!(tags.contains("content=\"A &quot;great&quot; 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        // Neither file is invented here: what a site advertises and what it
289        // allows are its own decisions.
290        assert!(!out.join("sitemap.xml").exists());
291        assert!(!out.join("robots.txt").exists());
292
293        // What it does offer is the material: every page it wrote, and the base
294        // URL to form them against.
295        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        // A leading slash is the same path, and nesting creates its directory.
308        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        // Written verbatim, and never counted as a page.
319        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}