1use 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#[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 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 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 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 pub fn frontmatter(&self, page: &str) -> Option<&P> {
246 self.pages.get(page)
247 }
248
249 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}