Skip to main content

sweet_potator/
generator.rs

1use std::{
2    collections::HashSet,
3    ffi::OsString,
4    fs,
5    path::{Path, PathBuf},
6};
7
8use serde::Serialize;
9use tera::Context;
10
11use crate::{
12    error::Result,
13    recipe::directory::Directory,
14    template,
15    util::{append_os_file_ext, copy_dir, UniqueNameFinder},
16};
17
18pub trait TextFilter {
19    fn filter<S: AsRef<str>>(&self, text: S) -> String;
20}
21
22const INDEX_NAME: &str = "index";
23
24const IMAGE_DIR: &str = "images";
25const RECIPE_DIR: &str = "recipes";
26const STATIC_DIR: &str = "static";
27
28#[derive(Debug, Serialize)]
29struct IndexEntry {
30    pub title: String,
31    pub path: PathBuf,
32    pub tags: Vec<String>,
33    pub image_path: Option<PathBuf>,
34}
35
36pub struct Generator<F> {
37    engine: template::Engine,
38    image_file_exts: Vec<OsString>,
39    output_file_ext: OsString,
40    file_name_filter: F,
41}
42
43impl<F> Generator<F> {
44    pub fn new(
45        engine: template::Engine,
46        image_file_exts: Vec<OsString>,
47        output_file_ext: OsString,
48        file_name_filter: F,
49    ) -> Self {
50        Self {
51            engine,
52            image_file_exts,
53            output_file_ext,
54            file_name_filter,
55        }
56    }
57}
58
59impl<F: TextFilter> Generator<F> {
60    pub fn generate(&self, recipe_dir: &Path, output_dir: &Path) -> Result<()> {
61        let index = self.render_recipes(recipe_dir, output_dir)?;
62        if self.engine.has_index_template() {
63            self.render_index(&index, output_dir)?;
64        }
65        let static_path = self.engine.static_path();
66        if static_path.exists() {
67            copy_dir(static_path, output_dir.join(STATIC_DIR))?;
68        }
69        Ok(())
70    }
71
72    fn render_index(&self, entries: &[IndexEntry], output_dir: &Path) -> Result<()> {
73        let tags = get_distinct_tags(entries);
74        let mut entries: Vec<_> = entries.iter().collect();
75        entries.sort_by_key(|entry| &entry.title);
76        let mut context = Context::new();
77        context.insert("recipes", &entries);
78        context.insert("tags", &tags);
79        let path = output_dir.join(append_os_file_ext(INDEX_NAME, &self.output_file_ext));
80        fs::create_dir_all(path.parent().expect("invalid index template path"))?;
81        let file = fs::File::create(path)?;
82        self.engine.render_index(context, file)
83    }
84
85    fn copy_image(
86        &self,
87        directory: &Directory,
88        slug: &str,
89        output_dir: &Path,
90    ) -> Result<Option<OsString>> {
91        directory
92            .image_file_name(&self.image_file_exts)?
93            .map(|name| {
94                let ext = Path::new(&name).extension().unwrap();
95                let new_name = append_os_file_ext(slug, ext);
96                fs::copy(directory.path().join(&name), output_dir.join(&new_name))?;
97                Ok(new_name)
98            })
99            .transpose()
100    }
101
102    fn render_recipes(&self, recipe_dir: &Path, output_dir: &Path) -> Result<Vec<IndexEntry>> {
103        let recipe_output_dir = output_dir.join(Path::new(RECIPE_DIR));
104        fs::create_dir_all(&recipe_output_dir)?;
105        let image_output_dir = output_dir.join(Path::new(IMAGE_DIR));
106        fs::create_dir_all(&image_output_dir)?;
107        let mut name_finder = UniqueNameFinder::new(" (", ")");
108        let mut index_entries = Vec::new();
109        for directory in Directory::list_all(recipe_dir)? {
110            let recipe = directory.load()?;
111            let name = name_finder.find(self.file_name_filter.filter(&recipe.title));
112            let recipe_file_name = append_os_file_ext(&name, &self.output_file_ext);
113            let relative_recipe_path = Path::new(RECIPE_DIR).join(&recipe_file_name);
114            let image_file_name = self.copy_image(&directory, &name, &image_output_dir)?;
115            let relative_image_path = image_file_name.map(|name| Path::new(IMAGE_DIR).join(name));
116            let mut context = Context::new();
117            context.insert("recipe", &recipe);
118            context.insert("path", relative_recipe_path.to_str().unwrap());
119            context.insert(
120                "image_path",
121                &relative_image_path
122                    .as_deref()
123                    .map(|path| path.to_str().unwrap()),
124            );
125            let mut tags = recipe.metadata.tags;
126            tags.sort();
127            let index_entry = IndexEntry {
128                title: recipe.title,
129                path: relative_recipe_path,
130                tags,
131                image_path: relative_image_path,
132            };
133            let file = fs::File::create(recipe_output_dir.join(recipe_file_name))?;
134            self.engine.render_recipe(context, file)?;
135            index_entries.push(index_entry);
136        }
137        Ok(index_entries)
138    }
139}
140
141fn get_distinct_tags(entries: &[IndexEntry]) -> Vec<&String> {
142    let mut tags = HashSet::new();
143    for entry in entries {
144        for tag in &entry.tags {
145            if tags.contains(tag) {
146            } else {
147                tags.insert(tag);
148            }
149        }
150    }
151    let mut tags: Vec<_> = tags.into_iter().collect();
152    tags.sort();
153    tags
154}
155
156#[cfg(test)]
157mod tests {
158    use std::{
159        collections::HashMap,
160        fs::File,
161        io::{Cursor, Write},
162    };
163
164    use tempfile::tempdir;
165
166    use crate::recipe::Recipe;
167
168    use super::*;
169
170    struct FileNameFilter;
171
172    impl TextFilter for FileNameFilter {
173        fn filter<S: AsRef<str>>(&self, text: S) -> String {
174            text.as_ref().to_uppercase()
175        }
176    }
177
178    #[test]
179    fn test() -> Result<()> {
180        // create temp directories
181        let temp_dir = tempdir()?;
182        let temp_path = temp_dir.path();
183        let tpl_dir = temp_path.join("template");
184        let static_dir = tpl_dir.join(STATIC_DIR);
185        let tera_dir = tpl_dir.join("tera");
186        let recipe_dir = temp_path.join(RECIPE_DIR);
187        let output_dir = temp_path.join("output");
188        fs::create_dir_all(&tpl_dir)?;
189        fs::create_dir_all(&static_dir)?;
190        fs::create_dir_all(&tera_dir)?;
191        fs::create_dir_all(&recipe_dir)?;
192        fs::create_dir_all(&output_dir)?;
193
194        // create recipe template
195        let mut file = File::create(tera_dir.join("recipe.html"))?;
196        writeln!(file, "title: {{{{ recipe.title }}}}")?;
197
198        // create index template
199        let mut file = File::create(tera_dir.join("index.html"))?;
200        writeln!(
201            file,
202            "{{% for r in recipes -%}}{{{{ r.title }}}}{{%- endfor %}}"
203        )?;
204
205        // default recipe
206        let recipe_str = "title\n\nYield: 1\n\nIngredients\n- nothing\n\nInstructions\n- none";
207
208        // create and store recipe 1
209        let mut recipe1 = Recipe::parse_from(Cursor::new(recipe_str))?;
210        recipe1.title = "recipe 1".into();
211        fs::create_dir(recipe_dir.join("recipe 1"))?;
212        let mut file = File::create(recipe_dir.join("recipe 1/recipe 1.recipe"))?;
213        writeln!(file, "{}", recipe1)?;
214
215        // create and store recipe 1 (2)
216        let mut recipe1_2 = Recipe::parse_from(Cursor::new(recipe_str))?;
217        recipe1_2.title = "recipe 1".into();
218        fs::create_dir(recipe_dir.join("recipe 1 (2)"))?;
219        let mut file = File::create(recipe_dir.join("recipe 1 (2)/recipe 1 (2).recipe"))?;
220        writeln!(file, "{}", recipe1_2)?;
221
222        // create and store recipe 2
223        let mut recipe2 = Recipe::parse_from(Cursor::new(recipe_str))?;
224        recipe2.title = "recipe 2".into();
225        fs::create_dir(recipe_dir.join("recipe 2"))?;
226        let mut file = File::create(recipe_dir.join("recipe 2/recipe 2.recipe"))?;
227        writeln!(file, "{}", recipe2)?;
228
229        // add image to recipe 2
230        File::create(recipe_dir.join("recipe 2/recipe 2.jpg"))?;
231
232        // create static content
233        File::create(static_dir.join("test.txt"))?;
234
235        // generate html
236        let mut context = Context::new();
237        context.insert(
238            "app",
239            &HashMap::from([("name", "name"), ("homepage", "homepage")]),
240        );
241        let mut engine = template::Engine::new(&tpl_dir, true, "html", None)?;
242        engine.forced_context = Some(context);
243        let image_file_exts = vec!["jpg".into()];
244
245        let generator = Generator::new(engine, image_file_exts, "html".into(), FileNameFilter);
246        generator.generate(&recipe_dir, &output_dir)?;
247
248        // validate html
249        let recipe_output_dir = output_dir.join(RECIPE_DIR);
250        let image_output_dir = output_dir.join(IMAGE_DIR);
251        let static_output_dir = output_dir.join(STATIC_DIR);
252        let recipe1 = fs::read_to_string(recipe_output_dir.join("RECIPE 1.html"))?;
253        assert_eq!(recipe1, "title: recipe 1\n");
254        let recipe1_2 = fs::read_to_string(recipe_output_dir.join("RECIPE 1 (2).html"))?;
255        assert_eq!(recipe1_2, "title: recipe 1\n");
256        let recipe2 = fs::read_to_string(recipe_output_dir.join("RECIPE 2.html"))?;
257        assert_eq!(recipe2, "title: recipe 2\n");
258        assert!(image_output_dir.join("RECIPE 2.jpg").exists());
259        let index = fs::read_to_string(output_dir.join("index.html"))?;
260        assert_eq!(index, concat!("recipe 1", "recipe 1", "recipe 2", "\n"));
261        assert!(static_output_dir.join("test.txt").exists());
262
263        Ok(())
264    }
265}