weaver_lib/
lib.rs

1use config::{TemplateLang, WeaverConfig};
2use document::Document;
3use futures::future::join_all;
4use glob::glob;
5use liquid::model::KString;
6use owo_colors::OwoColorize;
7use partial::Partial;
8use renderers::{
9    ContentRenderer, MarkdownRenderer, WritableFile,
10    globals::{LiquidGlobals, LiquidGlobalsPage},
11};
12use routes::route_from_path;
13use std::{collections::HashMap, error::Error, fmt::Display, path::PathBuf, sync::Arc};
14use template::Template;
15use tokio::sync::Mutex;
16
17/// Weaver is the library that powers weaving, as in Hugo Weaving. It does nothing but compile
18/// templates and markdown files to their static counterparts.
19/// There is zero requirement for a config file at all, defaults are used- however specifying
20/// content locations can vary from user to user so afford them the opportunity to do so.
21pub mod config;
22pub mod document;
23pub mod filters;
24pub mod partial;
25pub mod renderers;
26pub mod routes;
27pub mod template;
28
29use std::fs;
30use std::path::Path;
31
32fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<WritableFile, BuildError> {
33    fs::create_dir_all(&dst).unwrap();
34    for entry in fs::read_dir(src).unwrap() {
35        let entry = entry.unwrap();
36        let ty = entry.file_type().unwrap();
37        if ty.is_dir() {
38            copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
39        } else {
40            fs::copy(entry.path(), dst.as_ref().join(entry.file_name())).unwrap();
41        }
42    }
43    Ok(WritableFile {
44        contents: "".into(),
45        path: "".into(),
46    })
47}
48
49// Helper function to normalize line endings in a byte vector
50pub fn normalize_line_endings(bytes: &[u8]) -> String {
51    let s = str::from_utf8(bytes).expect("Invalid UTF-8 in WritableFile content");
52    // Replace all CRLF (\r\n) with LF (\n)
53    s.replace("\r\n", "\n")
54}
55
56#[derive(Debug)]
57pub enum BuildError {
58    Err(String),
59    IoError(String),
60    GlobError(String),
61    DocumentError(String),
62    TemplateError(String),
63    RouteError(String),
64    RenderError(String),
65    JoinError(String),
66}
67
68impl Error for BuildError {}
69
70impl Display for BuildError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            BuildError::Err(msg) => write!(f, "Generic Build Error: {}", msg),
74            BuildError::IoError(msg) => write!(f, "I/O Error: {}", msg),
75            BuildError::GlobError(msg) => write!(f, "Glob Error: {}", msg),
76            BuildError::DocumentError(msg) => write!(f, "Document Error: {}", msg),
77            BuildError::TemplateError(msg) => write!(f, "Template Error: {}", msg),
78            BuildError::RouteError(msg) => write!(f, "Route Error: {}", msg),
79            BuildError::RenderError(msg) => write!(f, "Render Error: {}", msg),
80            BuildError::JoinError(msg) => write!(f, "Task Join Error: {}", msg),
81        }
82    }
83}
84
85impl From<tokio::task::JoinError> for BuildError {
86    fn from(err: tokio::task::JoinError) -> Self {
87        BuildError::JoinError(err.to_string())
88    }
89}
90
91pub struct Weaver {
92    pub config: Arc<WeaverConfig>,
93    pub tags: Vec<String>,
94    pub routes: Vec<String>,
95    pub templates: Vec<Arc<Mutex<Template>>>,
96    pub documents: Vec<Arc<Mutex<Document>>>,
97    pub partials: Vec<Partial>,
98    all_documents_by_route: HashMap<KString, Arc<Mutex<Document>>>,
99}
100
101impl Weaver {
102    pub fn new(base_path: PathBuf) -> Self {
103        Self {
104            config: Arc::new(WeaverConfig::new_from_path(base_path)),
105            tags: vec![],
106            routes: vec![],
107            templates: vec![],
108            partials: vec![],
109            documents: vec![],
110            all_documents_by_route: HashMap::new(),
111        }
112    }
113
114    pub fn scan_content(&mut self) -> &mut Self {
115        for entry in glob(format!("{}/**/*.md", self.config.content_dir).as_str())
116            .expect("Failed to read glob pattern")
117        {
118            match entry {
119                Ok(path) => {
120                    let mut doc = Document::new_from_path(path.clone());
121
122                    self.tags.append(&mut doc.metadata.tags);
123                    // Assuming route_from_path is correct and returns String
124                    let route = route_from_path(self.config.content_dir.clone().into(), path);
125                    self.routes.push(route.clone());
126
127                    let doc_arc_mutex = Arc::new(Mutex::new(doc));
128                    self.documents.push(Arc::clone(&doc_arc_mutex));
129
130                    self.all_documents_by_route
131                        .insert(KString::from(route), doc_arc_mutex);
132                }
133                Err(e) => panic!("{:?}", e),
134            }
135        }
136
137        self
138    }
139
140    pub fn scan_partials(&mut self) -> &mut Self {
141        let extension = match self.config.templating_language {
142            TemplateLang::Liquid => ".liquid",
143        };
144        println!(
145            "Searching for {} templates in {}",
146            &extension, &self.config.partials_dir
147        );
148        for entry in glob(format!("{}/**/*{}", self.config.partials_dir, extension).as_str())
149            .expect("Failed to read glob pattern")
150        {
151            match entry {
152                Ok(pathbuf) => {
153                    println!(
154                        "Found partial {}, registering {}",
155                        pathbuf.display(),
156                        pathbuf.file_name().unwrap().to_string_lossy()
157                    );
158                    let partial = Partial::new_from_path(pathbuf);
159                    self.partials.push(partial);
160                }
161                Err(e) => panic!("{:?}", e), // Panics on glob iteration error
162            }
163        }
164
165        self
166    }
167
168    pub fn scan_templates(&mut self) -> &mut Self {
169        let extension = match self.config.templating_language {
170            TemplateLang::Liquid => ".liquid",
171        };
172        for entry in glob(format!("{}/**/*{}", self.config.template_dir, extension).as_str())
173            .expect("Failed to read glob pattern")
174        {
175            match entry {
176                Ok(pathbuf) => self
177                    .templates
178                    .push(Arc::new(Mutex::new(Template::new_from_path(pathbuf)))), // Panics on file read/parse errors
179                Err(e) => panic!("{:?}", e), // Panics on glob iteration error
180            }
181        }
182
183        self
184    }
185
186    async fn write_result_to_system(&self, target: WritableFile) -> Result<(), BuildError> {
187        let full_output_path = target.path.clone();
188
189        // Ensure parent directories exist
190        if let Some(parent) = full_output_path.parent() {
191            tokio::fs::create_dir_all(parent).await.map_err(|e| {
192                BuildError::IoError(format!(
193                    "Failed to create parent directories for {:?}: {}",
194                    full_output_path, e
195                ))
196            })?;
197        }
198
199        println!("Writing {}", full_output_path.display().green());
200        tokio::fs::write(&full_output_path, target.contents)
201            .await
202            .map_err(|e| {
203                BuildError::IoError(format!(
204                    "Failed to write file {:?}: {}",
205                    full_output_path, e
206                ))
207            })?;
208
209        Ok(())
210    }
211
212    // The main build orchestration function
213    pub async fn build(&self) -> Result<(), BuildError> {
214        let mut all_liquid_pages_map: HashMap<KString, LiquidGlobalsPage> = HashMap::new();
215        let mut convert_tasks = vec![];
216
217        for document_arc_mutex in self.documents.iter() {
218            let doc_arc_mutex_clone = Arc::clone(document_arc_mutex);
219            let config_arc = Arc::clone(&self.config);
220
221            convert_tasks.push(tokio::spawn(async move {
222                let doc_guard = doc_arc_mutex_clone.lock().await;
223                let route = route_from_path(
224                    config_arc.content_dir.clone().into(),
225                    doc_guard.at_path.clone().into(),
226                );
227                let liquid_page = LiquidGlobalsPage::from(&*doc_guard);
228
229                (KString::from(route), liquid_page)
230            }));
231        }
232
233        let converted_pages: Vec<Result<(KString, LiquidGlobalsPage), tokio::task::JoinError>> =
234            join_all(convert_tasks).await;
235
236        for result in converted_pages {
237            let (route, liquid_page) = result.map_err(|e| BuildError::JoinError(e.to_string()))?;
238            all_liquid_pages_map.insert(route, liquid_page);
239        }
240
241        let all_liquid_pages_map_arc = Arc::new(all_liquid_pages_map);
242
243        let templates_arc = Arc::new(self.templates.clone());
244        let config_arc = Arc::clone(&self.config);
245        let partials_arc = Arc::new(self.partials.clone());
246
247        let mut tasks = vec![];
248
249        for document_arc_mutex in &self.documents {
250            let document_arc = Arc::clone(document_arc_mutex);
251
252            let all_liquid_pages_map_clone = Arc::clone(&all_liquid_pages_map_arc);
253            let mut globals =
254                LiquidGlobals::new(Arc::clone(&document_arc), &all_liquid_pages_map_clone).await;
255
256            let templates = Arc::clone(&templates_arc);
257            let config = Arc::clone(&config_arc);
258            let partials = Arc::clone(&partials_arc);
259
260            let doc_task = tokio::spawn(async move {
261                let md_renderer =
262                    MarkdownRenderer::new(document_arc, templates, config, partials.to_vec());
263
264                md_renderer.render(&mut globals, partials.to_vec()).await
265            });
266
267            tasks.push(doc_task);
268        }
269
270        let public_copy_task = tokio::spawn(async move {
271            let config = Arc::clone(&config_arc);
272            let folder_name = config
273                .public_dir
274                .clone()
275                .split('/')
276                .next_back()
277                .unwrap()
278                .to_string();
279            let target = format!("{}/{}", config.build_dir.clone(), folder_name);
280
281            println!("Copying {} to {}", config.public_dir.clone(), &target);
282
283            copy_dir_all(config.public_dir.clone(), target)
284        });
285
286        tasks.push(public_copy_task);
287
288        let render_results: Vec<Result<Result<WritableFile, BuildError>, tokio::task::JoinError>> =
289            join_all(tasks).await; // Await all rendering tasks
290
291        // Process the results of all rendering tasks
292        for join_result in render_results {
293            match join_result {
294                Ok(render_result) => match render_result {
295                    Ok(writable_file) => {
296                        if writable_file.path.as_os_str() != "" {
297                            self.write_result_to_system(writable_file).await?;
298                        }
299                    }
300                    Err(render_error) => {
301                        eprintln!("Rendering error: {}", render_error.red());
302                        return Err(render_error);
303                    }
304                },
305                Err(join_error) => {
306                    eprintln!("Task join error: {}", join_error.red());
307                    return Err(BuildError::JoinError(join_error.to_string()));
308                }
309            }
310        }
311
312        Ok(())
313    }
314}