Skip to main content

ssg/
server.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Dev server infrastructure for the static site generator.
5
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use anyhow::{anyhow, Context, Result};
11use http_handle::Server;
12
13use crate::cmd;
14use crate::Paths;
15
16/// Pluggable transport that drives the dev server.
17///
18/// Production code uses [`HttpTransport`] (a thin wrapper around
19/// `http_handle::Server`); tests use a test-only `NoopTransport` which
20/// records the call without actually binding a port. The trait exists
21/// so every line of `serve_site` is unit-testable.
22pub trait ServeTransport {
23    /// Start serving `root` on `addr`. Implementations may block.
24    ///
25    /// # Errors
26    ///
27    /// Returns an error if the underlying transport fails to start.
28    fn start(&self, addr: &str, root: &str) -> Result<()>;
29}
30
31/// Production transport: starts an `http_handle::Server`.
32#[derive(Debug, Clone, Copy)]
33pub struct HttpTransport;
34
35impl ServeTransport for HttpTransport {
36    fn start(&self, addr: &str, root: &str) -> Result<()> {
37        let server = Server::new(addr, root);
38        let _ = server.start();
39        Ok(())
40    }
41}
42
43/// Resolves a `site_dir` `Path` into the `(addr, root)` pair the
44/// transport expects, returning an error if the path contains
45/// invalid UTF-8.
46///
47/// Extracted from `serve_site` so the path-to-string conversion can
48/// be unit-tested without invoking a transport.
49pub(crate) fn build_serve_address(site_dir: &Path) -> Result<(String, String)> {
50    let root = site_dir
51        .to_str()
52        .ok_or_else(|| {
53            anyhow!(
54                "Site directory path contains invalid UTF-8: {}",
55                site_dir.display()
56            )
57        })?
58        .to_string();
59    let addr = format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT);
60    Ok((addr, root))
61}
62
63/// Starts the dev server using a caller-supplied transport.
64///
65/// Extracted so test code can pass a no-op transport and still
66/// exercise the surrounding glue (path validation, address
67/// formatting). Production callers use [`serve_site`] which
68/// delegates to [`HttpTransport`].
69///
70/// # Errors
71///
72/// Returns an error if `site_dir` contains invalid UTF-8 or if the
73/// underlying transport fails.
74pub fn serve_site_with<T: ServeTransport>(
75    site_dir: &Path,
76    transport: &T,
77) -> Result<()> {
78    let (addr, root) = build_serve_address(site_dir)?;
79    transport.start(&addr, &root)
80}
81
82/// Converts a site directory path to a string and starts an HTTP server.
83///
84/// This function blocks while the server is running.
85///
86/// # Errors
87///
88/// Returns an error if `site_dir` contains invalid UTF-8.
89pub fn serve_site(site_dir: &Path) -> Result<()> {
90    serve_site_with(site_dir, &HttpTransport)
91}
92
93/// Configures and launches the development server.
94///
95/// Sets up a local server for testing and previewing the generated site.
96/// Handles file copying and server configuration for local development.
97///
98/// # Arguments
99///
100/// * `log_file` - Reference to the active log file
101/// * `date` - Current timestamp for logging
102/// * `paths` - All required directory paths
103/// * `serve_dir` - Directory to serve content from
104///
105/// # Returns
106///
107/// * `Ok(())` - If server starts successfully
108/// * `Err` - If server configuration or startup fails
109///
110/// # Examples
111///
112/// ```rust,no_run
113/// use std::path::PathBuf;
114/// use ssg::{Paths, handle_server, create_log_file};
115///
116/// fn main() -> anyhow::Result<()> {
117///     let mut log_file = create_log_file("./server.log")?;
118///     let date = ssg::now_iso();
119///     let paths = Paths {
120///         site: PathBuf::from("public"),
121///         content: PathBuf::from("content"),
122///         build: PathBuf::from("build"),
123///         template: PathBuf::from("templates"),
124///     };
125///     let serve_dir = PathBuf::from("serve");
126///
127///     handle_server(&mut log_file, &date, &paths, &serve_dir)?;
128///     Ok(())
129/// }
130/// ```
131///
132/// # Server Configuration
133///
134/// * Default port: 8000
135/// * Host: 127.0.0.1 (localhost)
136/// * Serves static files from the specified directory
137pub fn handle_server(
138    log_file: &mut fs::File,
139    date: &str,
140    paths: &Paths,
141    serve_dir: &PathBuf,
142) -> Result<()> {
143    // Log server initialization
144    writeln!(log_file, "[{date}] INFO process: Server initialization")?;
145
146    prepare_serve_dir(paths, serve_dir)?;
147
148    let host = cmd::resolve_host();
149    let port = cmd::resolve_port();
150    let addr = format!("{host}:{port}");
151
152    println!("\nStarting server at http://{addr}");
153    println!("Serving content from: {}", serve_dir.display());
154
155    let dir = serve_dir
156        .to_str()
157        .ok_or_else(|| anyhow::anyhow!("serve dir contains invalid UTF-8"))?
158        .to_string();
159    let bind = addr;
160
161    let server = Server::new(&bind, &dir);
162    let _ = server.start();
163    Ok(())
164}
165
166/// Generates a root index.html that reads the browser's language
167/// preference and redirects to the best matching locale directory.
168///
169/// The file is written at `site_dir/index.html`. If it already exists
170/// and was not generated by this function, it is left untouched.
171///
172/// # Errors
173///
174/// Returns an error if the file cannot be written.
175pub fn generate_locale_redirect(
176    site_dir: &Path,
177    available_locales: &[String],
178    default_locale: &str,
179) -> Result<()> {
180    let index_path = site_dir.join("index.html");
181
182    // If an index.html already exists and wasn't generated by us, leave it.
183    if index_path.exists() {
184        let existing = fs::read_to_string(&index_path).unwrap_or_default();
185        if !existing.contains("<!-- ssg-locale-redirect -->") {
186            return Ok(());
187        }
188    }
189
190    let locales_js: Vec<String> = available_locales
191        .iter()
192        .map(|l| format!("\"{l}\""))
193        .collect();
194    let locales_array = locales_js.join(",");
195    let default_url = format!("/{default_locale}/");
196
197    let html = format!(
198        r#"<!DOCTYPE html>
199<!-- ssg-locale-redirect -->
200<html>
201<head>
202<meta charset="utf-8">
203<script>
204(function() {{
205  var locales = [{locales_array}];
206  var defaultLocale = "{default_locale}";
207  var langs = navigator.languages || [navigator.language || defaultLocale];
208  for (var i = 0; i < langs.length; i++) {{
209    var lang = langs[i].toLowerCase();
210    for (var j = 0; j < locales.length; j++) {{
211      if (lang === locales[j] || lang.startsWith(locales[j] + "-")) {{
212        window.location.replace("/" + locales[j] + "/");
213        return;
214      }}
215    }}
216    var prefix = lang.split("-")[0];
217    for (var j = 0; j < locales.length; j++) {{
218      if (prefix === locales[j]) {{
219        window.location.replace("/" + locales[j] + "/");
220        return;
221      }}
222    }}
223  }}
224  window.location.replace("/" + defaultLocale + "/");
225}})();
226</script>
227<noscript>
228<meta http-equiv="refresh" content="0; url={default_url}">
229</noscript>
230</head>
231<body></body>
232</html>
233"#
234    );
235
236    fs::write(&index_path, &html)
237        .with_context(|| format!("Failed to write {}", index_path.display()))?;
238
239    println!(
240        "[i18n] Generated locale redirect at {}",
241        index_path.display()
242    );
243    Ok(())
244}
245
246/// Prepares the serve directory by creating it and copying site files.
247pub fn prepare_serve_dir(paths: &Paths, serve_dir: &PathBuf) -> Result<()> {
248    fs::create_dir_all(serve_dir)
249        .context("Failed to create serve directory")?;
250
251    println!("Setting up server...");
252    println!("Source: {}", paths.site.display());
253    println!("Serving from: {}", serve_dir.display());
254
255    if serve_dir != &paths.site {
256        crate::fs_ops::verify_and_copy_files_async(&paths.site, serve_dir)?;
257    }
258    Ok(())
259}
260
261#[cfg(test)]
262#[allow(clippy::unwrap_used, clippy::expect_used)]
263mod tests {
264
265    use super::*;
266    use std::sync::{Arc, Mutex};
267    use tempfile::tempdir;
268
269    /// Test transport that records `(addr, root)` and never blocks.
270    #[derive(Default)]
271    struct RecordingTransport {
272        calls: Arc<Mutex<Vec<(String, String)>>>,
273        fail: bool,
274    }
275
276    impl ServeTransport for RecordingTransport {
277        fn start(&self, addr: &str, root: &str) -> Result<()> {
278            self.calls
279                .lock()
280                .unwrap()
281                .push((addr.to_string(), root.to_string()));
282            if self.fail {
283                Err(anyhow!("synthetic transport failure"))
284            } else {
285                Ok(())
286            }
287        }
288    }
289
290    #[test]
291    fn build_serve_address_formats_addr_and_returns_root() {
292        let dir = tempdir().unwrap();
293        let (addr, root) = build_serve_address(dir.path()).unwrap();
294        assert!(
295            addr.contains(cmd::DEFAULT_HOST),
296            "addr should contain default host: {addr}"
297        );
298        assert!(
299            addr.contains(&cmd::DEFAULT_PORT.to_string()),
300            "addr should contain default port: {addr}"
301        );
302        assert_eq!(root, dir.path().to_str().unwrap());
303    }
304
305    #[test]
306    fn serve_site_with_invokes_transport_with_resolved_address() {
307        let dir = tempdir().unwrap();
308        let transport = RecordingTransport::default();
309        let calls = transport.calls.clone();
310        serve_site_with(dir.path(), &transport).unwrap();
311        let recorded = calls.lock().unwrap().clone();
312        assert_eq!(recorded.len(), 1);
313        let (addr, root) = &recorded[0];
314        assert!(addr.contains(cmd::DEFAULT_HOST));
315        assert_eq!(root, dir.path().to_str().unwrap());
316    }
317
318    #[test]
319    fn serve_site_with_propagates_transport_errors() {
320        let dir = tempdir().unwrap();
321        let transport = RecordingTransport {
322            calls: Default::default(),
323            fail: true,
324        };
325        let err = serve_site_with(dir.path(), &transport).unwrap_err();
326        assert!(
327            err.to_string().contains("synthetic transport failure"),
328            "transport error should bubble up, got: {err}"
329        );
330    }
331
332    #[test]
333    fn http_transport_implements_serve_transport() {
334        // Smoke test that HttpTransport satisfies the trait. We don't
335        // actually call .start() here because that would bind a port.
336        let _t: &dyn ServeTransport = &HttpTransport;
337    }
338
339    #[test]
340    fn generate_locale_redirect_creates_index_with_marker() {
341        let dir = tempdir().unwrap();
342        generate_locale_redirect(
343            dir.path(),
344            &["en".to_string(), "fr".to_string(), "de".to_string()],
345            "en",
346        )
347        .unwrap();
348
349        let index = dir.path().join("index.html");
350        assert!(index.exists(), "index.html should be written");
351
352        let html = fs::read_to_string(&index).unwrap();
353        assert!(html.contains("<!-- ssg-locale-redirect -->"));
354        assert!(html.contains("\"en\""));
355        assert!(html.contains("\"fr\""));
356        assert!(html.contains("\"de\""));
357        assert!(html.contains("/en/")); // default fallback
358    }
359
360    #[test]
361    fn generate_locale_redirect_overwrites_own_marker() {
362        let dir = tempdir().unwrap();
363
364        // First call writes the file.
365        generate_locale_redirect(dir.path(), &["en".to_string()], "en")
366            .unwrap();
367        let first = fs::read_to_string(dir.path().join("index.html")).unwrap();
368
369        // Second call with different locales must overwrite.
370        generate_locale_redirect(
371            dir.path(),
372            &["en".to_string(), "fr".to_string()],
373            "en",
374        )
375        .unwrap();
376        let second = fs::read_to_string(dir.path().join("index.html")).unwrap();
377
378        assert_ne!(first, second);
379        assert!(second.contains("\"fr\""));
380    }
381
382    #[test]
383    fn generate_locale_redirect_preserves_user_index_html() {
384        // If the user wrote their own index.html (no marker), don't overwrite.
385        let dir = tempdir().unwrap();
386        let user_html = "<html><body>my hand-written page</body></html>";
387        fs::write(dir.path().join("index.html"), user_html).unwrap();
388
389        generate_locale_redirect(dir.path(), &["en".to_string()], "en")
390            .unwrap();
391
392        let after = fs::read_to_string(dir.path().join("index.html")).unwrap();
393        assert_eq!(
394            after, user_html,
395            "user-authored index.html must not be overwritten"
396        );
397    }
398
399    #[test]
400    fn prepare_serve_dir_creates_dir_when_missing() {
401        let dir = tempdir().unwrap();
402        let site = dir.path().join("site");
403        fs::create_dir_all(&site).unwrap();
404        fs::write(site.join("a.html"), "x").unwrap();
405
406        let serve = dir.path().join("serve-out");
407        let paths = Paths {
408            site: site.clone(),
409            content: dir.path().join("content"),
410            build: dir.path().join("build"),
411            template: dir.path().join("templates"),
412        };
413
414        prepare_serve_dir(&paths, &serve).unwrap();
415
416        assert!(serve.exists(), "serve dir should be created");
417        assert!(
418            serve.join("a.html").exists(),
419            "files should be copied from site to serve dir"
420        );
421    }
422
423    #[test]
424    fn prepare_serve_dir_skips_copy_when_serve_equals_site() {
425        let dir = tempdir().unwrap();
426        let site = dir.path().join("site");
427        fs::create_dir_all(&site).unwrap();
428        fs::write(site.join("a.html"), "x").unwrap();
429
430        let paths = Paths {
431            site: site.clone(),
432            content: dir.path().join("content"),
433            build: dir.path().join("build"),
434            template: dir.path().join("templates"),
435        };
436
437        // serve_dir == site — should not re-copy (no-op).
438        prepare_serve_dir(&paths, &site).unwrap();
439        assert!(site.join("a.html").exists());
440    }
441
442    #[test]
443    fn build_serve_address_contains_host_and_port() {
444        let dir = tempdir().unwrap();
445        let (addr, root) = build_serve_address(dir.path()).unwrap();
446        assert_eq!(
447            addr,
448            format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT)
449        );
450        assert_eq!(root, dir.path().to_str().unwrap());
451    }
452
453    #[test]
454    fn serve_site_with_records_correct_root() {
455        let dir = tempdir().unwrap();
456        let sub = dir.path().join("deep").join("nested");
457        fs::create_dir_all(&sub).unwrap();
458        let transport = RecordingTransport::default();
459        let calls = transport.calls.clone();
460        serve_site_with(&sub, &transport).unwrap();
461        let recorded = calls.lock().unwrap();
462        assert_eq!(recorded[0].1, sub.to_str().unwrap());
463    }
464
465    #[test]
466    fn generate_locale_redirect_single_locale() {
467        let dir = tempdir().unwrap();
468        generate_locale_redirect(dir.path(), &["es".to_string()], "es")
469            .unwrap();
470        let html = fs::read_to_string(dir.path().join("index.html")).unwrap();
471        assert!(html.contains("\"es\""));
472        assert!(html.contains("/es/"));
473        assert!(html.contains("<!-- ssg-locale-redirect -->"));
474    }
475}