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 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
38pub mod theme;
39
40use std::fs;
41use std::io;
42use std::path::{Path, PathBuf};
43
44/// Page metadata rendered into a document's `<head>`: the title, description,
45/// optional canonical URL, and optional social-share image. [`Meta::head_tags`]
46/// renders the standard `<title>`, `<meta>`, and Open Graph tags so every page is
47/// described and shareable the same way.
48#[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    /// A page's title and description. Both appear in search results and social
58    /// cards, so keep them human and specific.
59    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    /// The canonical absolute URL for this page (also used as the Open Graph URL).
69    pub fn canonical(mut self, url: impl Into<String>) -> Self {
70        self.canonical = Some(url.into());
71        self
72    }
73
74    /// The absolute URL of the social-share image (Open Graph / Twitter card).
75    pub fn image(mut self, url: impl Into<String>) -> Self {
76        self.image = Some(url.into());
77        self
78    }
79
80    /// Renders the `<head>` tags for this page: title, description, canonical
81    /// link, and Open Graph / Twitter card metadata. Embed the result inside the
82    /// document `<head>`. All values are HTML-attribute escaped.
83    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
111/// A static site being assembled into an output directory. Pages are written as
112/// `index.html` under a directory per path (so URLs are clean and extensionless),
113/// and static assets are copied verbatim.
114pub struct StaticSite {
115    out: PathBuf,
116    base_url: String,
117    paths: Vec<String>,
118}
119
120impl StaticSite {
121    /// Prepares an output directory and records the site's base URL, for example
122    /// `https://acme.example`. The base URL is what [`Meta::canonical`] and any
123    /// absolute link the site builds are formed against.
124    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    /// Writes a page's rendered `html` for the URL `path`. The path maps to a
135    /// clean-URL file: `/` becomes `index.html`, `/features/` becomes
136    /// `features/index.html`. The path is recorded, and [`paths`](Self::paths)
137    /// reports it.
138    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    /// Copies a directory of static assets (CSS, fonts, images) from `from` into
149    /// the output at `to`, recursively.
150    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    /// Writes a file verbatim at `path`, relative to the output root.
155    ///
156    /// For the files a site decides for itself and a generator should not invent:
157    /// `robots.txt`, `humans.txt`, `llms.txt`, a `CNAME`, a verification token.
158    /// Unlike [`page`](Self::page) the contents are written as given, with no
159    /// clean-URL mapping, and the path is not added to the sitemap.
160    ///
161    /// The path is confined to the output directory: a leading slash is trimmed
162    /// and a `..` segment is refused, so a caller cannot write outside `dist`.
163    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    /// The site's base URL, without a trailing slash. Paired with
179    /// [`paths`](Self::paths) it gives the absolute URL of every page written,
180    /// which is what a sitemap is built from.
181    pub fn base_url(&self) -> &str {
182        &self.base_url
183    }
184
185    /// Every page path written so far, normalised, in the order they were added.
186    ///
187    /// This is the material a sitemap is built from, without this crate deciding
188    /// what the sitemap says. Which URLs a site advertises, at what priority and
189    /// change frequency, is the site's own policy.
190    pub fn paths(&self) -> &[String] {
191        &self.paths
192    }
193}
194
195/// Maps a URL path to its output file: `/` and `""` to `index.html`, and any
196/// other path to `<path>/index.html` for clean, extensionless URLs.
197fn 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
206/// Normalises a URL path for the sitemap: leading slash, trailing slash for
207/// directories, and `/` for the root.
208fn 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
217/// Escapes a string for use in HTML text or a double-quoted attribute.
218fn escape(s: &str) -> String {
219    s.replace('&', "&amp;")
220        .replace('<', "&lt;")
221        .replace('>', "&gt;")
222        .replace('"', "&quot;")
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 &amp; Co</title>"));
265        assert!(tags.contains("content=\"A &quot;great&quot; 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        // Neither file is invented here: what a site advertises and what it
291        // allows are its own decisions.
292        assert!(!out.join("sitemap.xml").exists());
293        assert!(!out.join("robots.txt").exists());
294
295        // What it does offer is the material: every page it wrote, and the base
296        // URL to form them against.
297        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        // A leading slash is the same path, and nesting creates its directory.
310        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        // Written verbatim, and never counted as a page.
321        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}