Skip to main content

ssg/
theme.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Theme resolution.
5//!
6//! A theme is a directory holding a layout set and the assets those
7//! layouts reference. The nine published SSG themes keep theirs under
8//! `_layouts/`; a project-shaped theme keeps it under `templates/`.
9//! Both are accepted, because which one a theme uses is not something a
10//! consumer should have to know.
11//!
12//! Before this existed, using a theme meant hand-writing a path into
13//! someone else's tree:
14//!
15//! ```toml
16//! template_dir = "../ssg-themes.github.io/themes/quill/_layouts"
17//! ```
18//!
19//! which breaks the moment the theme moves, says nothing about which
20//! theme it is, and gives no error worth reading when it is wrong. A
21//! name is resolved instead:
22//!
23//! ```toml
24//! theme = "quill"
25//! ```
26
27use std::path::{Path, PathBuf};
28
29/// Directory names inside a theme that may hold its layouts, in the
30/// order they are tried.
31const LAYOUT_DIRS: [&str; 2] = ["_layouts", "templates"];
32
33/// Why a theme name did not resolve.
34///
35/// Both variants carry what was searched. A theme that cannot be found
36/// is nearly always a path problem, and a message that does not say
37/// where it looked leaves the reader guessing.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ThemeError {
40    /// No directory of that name in any search root.
41    NotFound {
42        /// The requested theme name.
43        name: String,
44        /// Every directory that was searched, in order.
45        searched: Vec<PathBuf>,
46        /// Theme names that do exist in those roots.
47        available: Vec<String>,
48    },
49    /// The directory exists but holds no layouts.
50    NoLayouts {
51        /// The requested theme name.
52        name: String,
53        /// The directory that was found.
54        dir: PathBuf,
55    },
56}
57
58impl std::fmt::Display for ThemeError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::NotFound {
62                name,
63                searched,
64                available,
65            } => {
66                write!(f, "no theme named `{name}`. Searched: ")?;
67                let paths: Vec<String> =
68                    searched.iter().map(|p| p.display().to_string()).collect();
69                write!(f, "{}", paths.join(", "))?;
70                if available.is_empty() {
71                    write!(
72                        f,
73                        ". No themes found. Put one under `themes/{name}/`, \
74                         or set SSG_THEME_PATH to the directory holding your \
75                         themes."
76                    )
77                } else {
78                    write!(f, ". Available: {}", available.join(", "))
79                }
80            }
81            Self::NoLayouts { name, dir } => write!(
82                f,
83                "theme `{name}` at {} has no layouts. Expected one of {} \
84                 inside it.",
85                dir.display(),
86                LAYOUT_DIRS.join(" or ")
87            ),
88        }
89    }
90}
91
92impl std::error::Error for ThemeError {}
93
94/// A resolved theme.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Theme {
97    /// The theme's own directory.
98    pub root: PathBuf,
99    /// The directory holding its layouts — what `template_dir` becomes.
100    pub template_dir: PathBuf,
101}
102
103/// The roots a theme name is searched in, in order.
104///
105/// `base` first — the directory of the config file naming the theme, so
106/// a project's own `themes/` wins over anything installed globally.
107/// Then the working directory, then each entry of `SSG_THEME_PATH`.
108#[must_use]
109pub fn search_roots(base: &Path) -> Vec<PathBuf> {
110    let mut roots = vec![base.join("themes")];
111    if let Ok(cwd) = std::env::current_dir() {
112        let cwd_themes = cwd.join("themes");
113        if !roots.contains(&cwd_themes) {
114            roots.push(cwd_themes);
115        }
116    }
117    if let Ok(extra) = std::env::var("SSG_THEME_PATH") {
118        for part in extra.split(if cfg!(windows) { ';' } else { ':' }) {
119            let part = part.trim();
120            if !part.is_empty() {
121                let p = PathBuf::from(part);
122                if !roots.contains(&p) {
123                    roots.push(p);
124                }
125            }
126        }
127    }
128    roots
129}
130
131/// Lists the theme names present in `roots`, sorted and deduplicated.
132#[must_use]
133pub fn available(roots: &[PathBuf]) -> Vec<String> {
134    let mut names: Vec<String> = roots
135        .iter()
136        .filter_map(|r| std::fs::read_dir(r).ok())
137        .flat_map(|entries| {
138            entries
139                .flatten()
140                .filter(|e| e.path().is_dir())
141                .filter_map(|e| e.file_name().into_string().ok())
142        })
143        .filter(|n| !n.starts_with('.'))
144        .collect();
145    names.sort();
146    names.dedup();
147    names
148}
149
150/// Resolves a theme name against `base`.
151///
152/// # Errors
153///
154/// [`ThemeError::NotFound`] when no search root holds a directory of
155/// that name, and [`ThemeError::NoLayouts`] when one does but contains
156/// neither `_layouts/` nor `templates/`.
157///
158/// # Examples
159///
160/// ```
161/// # use std::fs;
162/// # let tmp = tempfile::tempdir().unwrap();
163/// # fs::create_dir_all(tmp.path().join("themes/quill/_layouts")).unwrap();
164/// let theme = ssg::theme::resolve("quill", tmp.path()).unwrap();
165/// assert!(theme.template_dir.ends_with("_layouts"));
166/// ```
167pub fn resolve(name: &str, base: &Path) -> Result<Theme, ThemeError> {
168    let roots = search_roots(base);
169    for root in &roots {
170        let dir = root.join(name);
171        if !dir.is_dir() {
172            continue;
173        }
174        for layout in LAYOUT_DIRS {
175            let candidate = dir.join(layout);
176            if candidate.is_dir() {
177                return Ok(Theme {
178                    root: dir,
179                    template_dir: candidate,
180                });
181            }
182        }
183        return Err(ThemeError::NoLayouts {
184            name: name.to_string(),
185            dir,
186        });
187    }
188    Err(ThemeError::NotFound {
189        name: name.to_string(),
190        available: available(&roots),
191        searched: roots,
192    })
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::fs;
199    use tempfile::TempDir;
200
201    fn theme_at(root: &Path, name: &str, layout_dir: &str) {
202        fs::create_dir_all(root.join("themes").join(name).join(layout_dir))
203            .expect("create theme");
204    }
205
206    #[test]
207    fn resolves_a_layouts_theme() {
208        let tmp = TempDir::new().expect("tempdir");
209        theme_at(tmp.path(), "quill", "_layouts");
210        let theme = resolve("quill", tmp.path()).expect("resolve");
211        assert!(theme.template_dir.ends_with("_layouts"));
212        assert!(theme.root.ends_with("quill"));
213    }
214
215    /// A project-shaped theme keeps its layouts in `templates/`. Which
216    /// convention a theme uses is not something a consumer should have
217    /// to know, so both resolve.
218    #[test]
219    fn resolves_a_templates_theme() {
220        let tmp = TempDir::new().expect("tempdir");
221        theme_at(tmp.path(), "housestyle", "templates");
222        let theme = resolve("housestyle", tmp.path()).expect("resolve");
223        assert!(theme.template_dir.ends_with("templates"));
224    }
225
226    /// `_layouts` is tried first so a theme carrying both is read the
227    /// way its author publishes it.
228    #[test]
229    fn layouts_wins_when_a_theme_carries_both() {
230        let tmp = TempDir::new().expect("tempdir");
231        theme_at(tmp.path(), "both", "_layouts");
232        theme_at(tmp.path(), "both", "templates");
233        let theme = resolve("both", tmp.path()).expect("resolve");
234        assert!(theme.template_dir.ends_with("_layouts"));
235    }
236
237    /// The error has to say where it looked. A theme that does not
238    /// resolve is nearly always a path problem, and a bare "not found"
239    /// leaves the reader with nothing to check.
240    #[test]
241    fn a_missing_theme_reports_the_paths_searched_and_what_exists() {
242        let tmp = TempDir::new().expect("tempdir");
243        theme_at(tmp.path(), "quill", "_layouts");
244        theme_at(tmp.path(), "stablo", "_layouts");
245        let err = resolve("nosuch", tmp.path()).expect_err("must not resolve");
246        let msg = err.to_string();
247        assert!(msg.contains("nosuch"), "{msg}");
248        assert!(msg.contains("themes"), "should name a search root: {msg}");
249        assert!(msg.contains("quill"), "should list what exists: {msg}");
250        assert!(msg.contains("stablo"), "should list what exists: {msg}");
251    }
252
253    /// A directory of the right name but with no layouts is a different
254    /// mistake from a missing one, and gets a different message.
255    #[test]
256    fn a_theme_without_layouts_is_reported_as_such() {
257        let tmp = TempDir::new().expect("tempdir");
258        fs::create_dir_all(tmp.path().join("themes/hollow"))
259            .expect("create dir");
260        let err = resolve("hollow", tmp.path()).expect_err("must not resolve");
261        let msg = err.to_string();
262        assert!(msg.contains("has no layouts"), "{msg}");
263        assert!(msg.contains("_layouts"), "should say what it wanted: {msg}");
264    }
265
266    #[test]
267    fn available_lists_theme_names_sorted_without_dotfiles() {
268        let tmp = TempDir::new().expect("tempdir");
269        theme_at(tmp.path(), "voxt", "_layouts");
270        theme_at(tmp.path(), "apex", "_layouts");
271        fs::create_dir_all(tmp.path().join("themes/.hidden"))
272            .expect("hidden dir");
273        let names = available(&[tmp.path().join("themes")]);
274        assert_eq!(names, vec!["apex".to_string(), "voxt".to_string()]);
275    }
276
277    /// The config file's own directory is searched before anything
278    /// else, so a build does not depend on where it was invoked from.
279    #[test]
280    fn the_base_directory_is_the_first_search_root() {
281        let tmp = TempDir::new().expect("tempdir");
282        let roots = search_roots(tmp.path());
283        assert_eq!(roots.first(), Some(&tmp.path().join("themes")));
284    }
285}