teensy_cms/
lib.rs

1//! TeensyCMS is the smallest CMS possible that allows admins running your application to add custom
2//! pages that are accessible from a nav bar.
3//!
4//! An implementation of TeensyCMS with [`actix-web`](https://actix.rs/) can be found in the source
5//! code's example directory.
6//!
7//! ```no_run
8//! # struct MyConfig {
9//! #     pages_config_path: &'static str,
10//! # }
11//! # impl MyConfig {
12//! #     fn from_env() -> Self { todo!() }
13//! # }
14//! # struct Request {}
15//! # impl Request {
16//! #     fn data<T>(&self) -> Option<T> { todo!() }
17//! #     fn path_args(&self) -> std::collections::HashMap<&'static str, String> { todo!() }
18//! # }
19//! use teensy_cms::{TeensyCms, DefaultPage};
20//!
21//! let my_config = MyConfig::from_env();
22//! let cms = TeensyCms::<DefaultPage>::from_config_path(
23//!     "/page",
24//!     &my_config.pages_config_path
25//! ).unwrap();
26//!
27//! /* -- server initialization here -- */
28//!
29//! fn handle_page_request(req: Request) -> String {
30//!     // something like "contact" or "about"
31//!     let page = &req.path_args()["page"];
32//!     req.data::<TeensyCms<DefaultPage>>().unwrap()
33//!         .render(&format!("{page}.html")).unwrap()
34//! }
35//! ```
36//!
37//! Pages use YAML frontmatter followed by HTML.
38//! The frontmatter **MUST** be delimited by `---` both before and after.
39//! Only a single YAML document is permitted in the frontmatter.
40//!
41//! ```yaml
42//! ---
43//! title: My Page
44//! cats: [Scruffles, Mx. Clawz]
45//! ---
46//! <h1>{{ page.title }}</h1>
47//! <ul>
48//!   {% for cat in page.cats %}
49//!     <li>{{ cat }}</li>
50//!   {% endfor %}
51//! </ul>
52//! ```
53use serde::Serialize;
54use std::collections::{HashMap, HashSet};
55use std::fs::{self, File};
56use std::path::{Path, PathBuf};
57use tera::{Context, Tera};
58use walkdir::WalkDir;
59
60mod config;
61mod error;
62mod menu;
63mod page;
64
65pub use config::{Config, MenuItemConfig, Visibility};
66pub use error::{Error, Result};
67pub use menu::{Menu, MenuItem};
68pub use page::{DefaultPage, Page};
69
70#[cfg(windows)]
71const FRONT_MATTER_DELIMITER: &str = "---\r\n";
72#[cfg(not(windows))]
73const FRONT_MATTER_DELIMITER: &str = "---\n";
74
75/// The main CMS struct.
76#[derive(Debug)]
77pub struct TeensyCms<P: Page> {
78    pages: HashMap<String, P>,
79    tera: Tera,
80    menu: Menu,
81}
82
83impl<P: Page> TeensyCms<P> {
84    /// Loads the CMS from a directory and strips the directory's prefix from the template names.
85    pub fn from_config_path(
86        url_root: impl AsRef<str>,
87        config_path: impl AsRef<Path>,
88    ) -> Result<TeensyCms<P>> {
89        let config_path = config_path.as_ref().canonicalize()?;
90
91        #[cfg(feature = "logging")]
92        log::debug!(
93            "Initializing TeensyCMS using config at: {}",
94            config_path.display()
95        );
96
97        let config: Config = serde_yaml::from_reader(File::open(&config_path)?)?;
98        let root_path_components = config_path.components().count() - 1;
99        let root_path = config_path.parent().unwrap();
100
101        let mut tera = Tera::default();
102        // we need to load possible parent templates before loading the child templates
103        if let Some(extras) = &config.template_extras {
104            for extra in extras {
105                let extra = root_path.join(extra);
106                match extra.to_str().and_then(|p| glob::glob(p).ok()) {
107                    Some(path_iter) => {
108                        for path in path_iter.filter_map(|e| e.ok()) {
109                            add_tera_template(root_path_components, &path, &mut tera)?;
110                        }
111                    }
112                    None => {
113                        for entry in WalkDir::new(extra)
114                            .into_iter()
115                            .filter_entry(|e| e.file_type().is_file())
116                            .filter_map(|e| e.ok())
117                        {
118                            add_tera_template(root_path_components, entry.path(), &mut tera)?;
119                        }
120                    }
121                }
122            }
123        } else {
124            #[cfg(feature = "logging")]
125            log::debug!("No template extras configured, not loading any.");
126        }
127
128        let mut pages = HashMap::new();
129        let mut seen_urls = HashSet::new();
130        let menu = Self::register_pages(
131            root_path,
132            root_path_components,
133            url_root.as_ref(),
134            &config.pages,
135            &mut tera,
136            &mut pages,
137            &mut seen_urls,
138        )?;
139
140        #[cfg(feature = "logging")]
141        log::debug!("CMS successfully initialized.");
142        Ok(Self { pages, tera, menu })
143    }
144
145    fn register_pages(
146        root_path: &Path,
147        root_path_components: usize,
148        root_url: &str,
149        menu_item_configs: &[MenuItemConfig],
150        tera: &mut Tera,
151        pages: &mut HashMap<String, P>,
152        seen_urls: &mut HashSet<String>,
153    ) -> Result<Menu> {
154        let mut menu = Menu(Vec::new());
155
156        for menu_item_config in menu_item_configs {
157            match menu_item_config {
158                MenuItemConfig::Page {
159                    path,
160                    url,
161                    visibility,
162                } => {
163                    #[cfg(feature = "logging")]
164                    log::debug!("Parsing page with relative page: {:?}", path);
165
166                    if seen_urls.contains(url) {
167                        return Err(Error::DuplicateUrl(url.to_string()));
168                    }
169                    seen_urls.insert(url.clone());
170
171                    let full_path = root_path.join(path);
172                    let template_str = fs::read_to_string(&full_path)?;
173                    let (page, content) = parse_page::<P>(&template_str)?;
174
175                    let page_title = page.title();
176                    for current_item in menu.iter() {
177                        if current_item.title() == page_title {
178                            return Err(Error::DuplicateMenuItem(page_title));
179                        }
180                    }
181
182                    let template_path = full_path
183                        .components()
184                        .skip(root_path_components)
185                        .collect::<PathBuf>();
186                    let template_path = template_path
187                        .to_str()
188                        .ok_or_else(|| Error::InvalidPath(template_path.clone()))?;
189
190                    #[cfg(feature = "logging")]
191                    log::debug!(
192                        "Adding template {:?} for page {:?}",
193                        template_path,
194                        page_title
195                    );
196
197                    menu.0.push(MenuItem::Page {
198                        title: page_title,
199                        url: url_join(root_url, url),
200                        visibility: *visibility,
201                    });
202                    pages.insert(template_path.to_string(), page);
203                    tera.add_raw_template(template_path, content)?;
204                }
205                MenuItemConfig::Menu {
206                    title,
207                    pages: menu_item_configs,
208                } => {
209                    for current_item in menu.iter() {
210                        if current_item.title() == title {
211                            return Err(Error::DuplicateMenuItem(title.to_string()));
212                        }
213                    }
214
215                    let submenu = Self::register_pages(
216                        root_path,
217                        root_path_components,
218                        root_url,
219                        menu_item_configs,
220                        tera,
221                        pages,
222                        seen_urls,
223                    )?;
224
225                    menu.0.push(MenuItem::Menu {
226                        title: title.to_string(),
227                        menu: submenu,
228                    });
229                }
230            };
231        }
232
233        Ok(menu)
234    }
235
236    /// Render a page by name.
237    pub fn render(&self, page: &str) -> Result<String> {
238        match self.pages.get(page) {
239            Some(page_data) => Ok(self.tera.render(page, &get_context(page_data)?)?),
240            None => Err(Error::PageNotFound),
241        }
242    }
243
244    /// Return the frontmatter for a page by name.
245    pub fn frontmatter(&self, page: &str) -> Option<&P> {
246        self.pages.get(page)
247    }
248
249    /// Get a reference to the CMS's generated [`Menu`].
250    pub fn menu(&self) -> &Menu {
251        &self.menu
252    }
253}
254
255#[derive(Serialize)]
256struct ConextHelper<'a, P: Page> {
257    page: &'a P,
258}
259
260#[inline]
261fn get_context<P: Page>(page: &P) -> Result<Context> {
262    Ok(Context::from_serialize(ConextHelper { page })?)
263}
264
265fn parse_page<P: Page>(page_str: &str) -> Result<(P, &str)> {
266    if !page_str.starts_with(FRONT_MATTER_DELIMITER) {
267        return Err(Error::NoFrontmatter);
268    }
269    let end_idx = match &page_str[FRONT_MATTER_DELIMITER.len()..].find(FRONT_MATTER_DELIMITER) {
270        Some(idx) => *idx + FRONT_MATTER_DELIMITER.len(),
271        None => return Err(Error::MalformedFrontmatter),
272    };
273    let yaml_str = &page_str[0..end_idx];
274    let page = serde_yaml::from_str(yaml_str)?;
275    let content = &page_str[(end_idx + FRONT_MATTER_DELIMITER.len())..];
276    Ok((page, content))
277}
278
279fn url_join(root: &str, rest: &str) -> String {
280    let root = root.trim_end_matches('/');
281    let rest = rest.trim_start_matches('/');
282    let mut joined = String::with_capacity(root.len() + rest.len() + 1);
283    joined.push_str(root);
284    joined.push('/');
285    joined.push_str(rest);
286    joined
287}
288
289fn add_tera_template(root_path_components: usize, path: &Path, tera: &mut Tera) -> Result<()> {
290    let template_path = path
291        .components()
292        .skip(root_path_components)
293        .collect::<PathBuf>();
294    let template_path = template_path
295        .to_str()
296        .ok_or_else(|| Error::InvalidPath(template_path.clone()))?;
297    let template = fs::read_to_string(path)?;
298
299    #[cfg(feature = "logging")]
300    log::debug!(
301        "Loading template extra {:?} from path {:?}",
302        template_path,
303        path.display()
304    );
305    tera.add_raw_template(template_path, &template)?;
306    Ok(())
307}
308
309#[cfg(test)]
310mod test {
311    use super::*;
312
313    fn get_cms(test_name: &str) -> Result<TeensyCms<DefaultPage>> {
314        let config_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
315            .join("dev-data")
316            .join(test_name)
317            .join("teensy.yml");
318        TeensyCms::<DefaultPage>::from_config_path("/page", config_path)
319    }
320
321    #[test]
322    fn parse_success() {
323        let template = format!(
324            "{}title: foo\n{}my page data",
325            FRONT_MATTER_DELIMITER, FRONT_MATTER_DELIMITER
326        );
327        let (page, content) = parse_page::<DefaultPage>(&template).unwrap();
328        let expected_page = DefaultPage {
329            title: "foo".to_string(),
330            extra: serde_yaml::Mapping::new(),
331        };
332        assert_eq!(page, expected_page);
333        assert_eq!(content, "my page data");
334    }
335
336    #[test]
337    fn simple() {
338        let cms = get_cms("simple").unwrap();
339        let page = cms.render("about.html").unwrap();
340        assert_eq!(
341            page.trim(),
342            "<p>This is an about page with no template usage.</p>"
343        );
344    }
345
346    #[test]
347    fn missing_dir() {
348        let err = get_cms("REALLY-DEFINITELY-DOES-NOT-EXIST").unwrap_err();
349        assert!(matches!(err, Error::Io(_)));
350    }
351
352    #[test]
353    fn err_menu_collisions() {
354        let err = get_cms("duplicate-menu-error").unwrap_err();
355        assert!(matches!(err, Error::DuplicateMenuItem(x) if x == "About"));
356    }
357
358    #[test]
359    fn err_url_collisions() {
360        let err = get_cms("duplicate-url-error").unwrap_err();
361        assert!(matches!(err, Error::DuplicateUrl(x) if x == "/about"));
362    }
363
364    #[test]
365    fn nested_menu() {
366        let cms = get_cms("nested-menu").unwrap();
367        let page = cms.render("cats/beans.html").unwrap();
368        assert_eq!(page.trim(), "<p>This page is about the cat Beans.</p>");
369
370        let expected_menu = Menu(vec![
371            MenuItem::Page {
372                title: "About".to_string(),
373                url: "/page/about".to_string(),
374                visibility: Default::default(),
375            },
376            MenuItem::Menu {
377                title: "Cats".to_string(),
378                menu: Menu(vec![
379                    MenuItem::Page {
380                        title: "Beans".to_string(),
381                        url: "/page/cats/beans".to_string(),
382                        visibility: Default::default(),
383                    },
384                    MenuItem::Page {
385                        title: "Miette".to_string(),
386                        url: "/page/cats/miette".to_string(),
387                        visibility: Default::default(),
388                    },
389                ]),
390            },
391        ]);
392
393        assert_eq!(cms.menu, expected_menu);
394    }
395
396    #[test]
397    fn template_extras() {
398        let cms = get_cms("template-extras").unwrap();
399        let page = cms.render("test.html").unwrap();
400        assert_eq!(page.trim(), "<p>Layout: Test</p>");
401    }
402}