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 syntect::{
15    highlighting::ThemeSet,
16    html::{ClassStyle, css_for_theme_with_class_style},
17};
18use tasks::{
19    WeaverTask, atom_feed_task::AtomFeedTask, public_copy_task::PublicCopyTask,
20    sitemap_task::SiteMapTask, well_known_copy_task::WellKnownCopyTask,
21};
22use template::Template;
23use tokio::{sync::Mutex, task::JoinHandle};
24
25/// Weaver is the library that powers weaving, as in Hugo Weaving. It is the manager of all things
26/// to do with the building of your site and all of it's content.
27/// There is zero requirement for a config file at all, defaults are used- however specifying
28/// content locations can vary from user to user so afford them the opportunity to do so.
29pub mod config;
30pub mod document;
31pub mod document_toc;
32pub mod filters;
33pub mod partial;
34pub mod renderers;
35pub mod routes;
36pub mod slugify;
37pub mod tasks;
38pub mod template;
39
40// Helper function to normalize line endings in a byte vector
41pub fn normalize_line_endings(bytes: &[u8]) -> String {
42    let s = str::from_utf8(bytes).expect("Invalid UTF-8 in WritableFile content");
43    // Replace all CRLF (\r\n) with LF (\n)
44    s.replace("\r\n", "\n")
45}
46
47#[derive(Debug)]
48pub enum BuildError {
49    Err(String),
50    IoError(String),
51    GlobError(String),
52    DocumentError(String),
53    TemplateError(String),
54    RouteError(String),
55    RenderError(String),
56    JoinError(String),
57}
58
59impl Error for BuildError {}
60
61impl Display for BuildError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            BuildError::Err(msg) => write!(f, "Generic Build Error: {}", msg),
65            BuildError::IoError(msg) => write!(f, "I/O Error: {}", msg),
66            BuildError::GlobError(msg) => write!(f, "Glob Error: {}", msg),
67            BuildError::DocumentError(msg) => write!(f, "Document Error: {}", msg),
68            BuildError::TemplateError(msg) => write!(f, "Template Error: {}", msg),
69            BuildError::RouteError(msg) => write!(f, "Route Error: {}", msg),
70            BuildError::RenderError(msg) => write!(f, "Render Error: {}", msg),
71            BuildError::JoinError(msg) => write!(f, "Task Join Error: {}", msg),
72        }
73    }
74}
75
76impl From<tokio::task::JoinError> for BuildError {
77    fn from(err: tokio::task::JoinError) -> Self {
78        BuildError::JoinError(err.to_string())
79    }
80}
81
82pub struct Weaver {
83    pub config: Arc<WeaverConfig>,
84    pub tags: Vec<String>,
85    pub routes: Vec<String>,
86    pub templates: Vec<Arc<Mutex<Template>>>,
87    pub documents: Vec<Arc<Mutex<Document>>>,
88    pub partials: Vec<Partial>,
89    pub all_documents_by_route: HashMap<KString, Arc<Mutex<Document>>>,
90    tasks: Vec<Arc<Box<dyn WeaverTask>>>,
91}
92
93impl Weaver {
94    pub fn new(base_path: PathBuf) -> Self {
95        Self {
96            config: Arc::new(WeaverConfig::new(base_path)),
97            tags: vec![],
98            routes: vec![],
99            templates: vec![],
100            partials: vec![],
101            documents: vec![],
102            all_documents_by_route: HashMap::new(),
103            tasks: vec![
104                Arc::new(Box::new(PublicCopyTask {})),
105                Arc::new(Box::new(WellKnownCopyTask {})),
106                Arc::new(Box::new(SiteMapTask {})),
107                Arc::new(Box::new(AtomFeedTask {})),
108            ],
109        }
110    }
111
112    pub fn scan_content(&mut self) -> &mut Self {
113        for entry in glob(format!("{}/**/*.md", self.config.content_dir).as_str())
114            .expect("Failed to read glob pattern")
115        {
116            match entry {
117                Ok(path) => {
118                    let mut doc = Document::new_from_path(
119                        self.config.content_dir.clone().into(),
120                        path.clone(),
121                    );
122
123                    self.tags.append(&mut doc.metadata.tags);
124                    // Assuming route_from_path is correct and returns String
125                    let route = route_from_path(self.config.content_dir.clone().into(), path);
126                    self.routes.push(route.clone());
127
128                    let doc_arc_mutex = Arc::new(Mutex::new(doc));
129                    self.documents.push(Arc::clone(&doc_arc_mutex));
130
131                    self.all_documents_by_route
132                        .insert(KString::from(route), doc_arc_mutex);
133                }
134                Err(e) => panic!("{:?}", e),
135            }
136        }
137
138        self
139    }
140
141    pub fn scan_partials(&mut self) -> &mut Self {
142        let extension = match self.config.templating_language {
143            TemplateLang::Liquid => ".liquid",
144        };
145        println!(
146            "Searching for {} templates in {}",
147            &extension, &self.config.partials_dir
148        );
149        for entry in glob(format!("{}/**/*{}", self.config.partials_dir, extension).as_str())
150            .expect("Failed to read glob pattern")
151        {
152            match entry {
153                Ok(pathbuf) => {
154                    println!(
155                        "Found partial {}, registering {}",
156                        pathbuf.display(),
157                        pathbuf.file_name().unwrap().to_string_lossy()
158                    );
159                    let partial = Partial::new_from_path(pathbuf);
160                    self.partials.push(partial);
161                }
162                Err(e) => panic!("{:?}", e), // Panics on glob iteration error
163            }
164        }
165
166        self
167    }
168
169    pub fn scan_templates(&mut self) -> &mut Self {
170        let extension = match self.config.templating_language {
171            TemplateLang::Liquid => ".liquid",
172        };
173        for entry in glob(format!("{}/**/*{}", self.config.template_dir, extension).as_str())
174            .expect("Failed to read glob pattern")
175        {
176            match entry {
177                Ok(pathbuf) => self
178                    .templates
179                    .push(Arc::new(Mutex::new(Template::new_from_path(pathbuf)))), // Panics on file read/parse errors
180                Err(e) => panic!("{:?}", e), // Panics on glob iteration error
181            }
182        }
183
184        self
185    }
186
187    async fn write_result_to_system(&self, target: WritableFile) -> Result<(), BuildError> {
188        let full_output_path = target.path.clone();
189
190        // Ensure parent directories exist
191        if let Some(parent) = full_output_path.parent() {
192            tokio::fs::create_dir_all(parent).await.map_err(|e| {
193                BuildError::IoError(format!(
194                    "Failed to create parent directories for {:?}: {}",
195                    full_output_path, e
196                ))
197            })?;
198        }
199
200        println!("Writing {}", full_output_path.display().green());
201        tokio::fs::write(&full_output_path, target.contents)
202            .await
203            .map_err(|e| {
204                BuildError::IoError(format!(
205                    "Failed to write file {:?}: {}",
206                    full_output_path, e
207                ))
208            })?;
209
210        Ok(())
211    }
212
213    fn get_css_for_theme(&self) -> String {
214        // Load all built-in themes
215        let theme_set = ThemeSet::load_defaults();
216
217        // Try to find the theme by name
218        if let Some(theme) = theme_set.themes.get(&self.config.syntax_theme) {
219            css_for_theme_with_class_style(theme, ClassStyle::Spaced).unwrap()
220        } else {
221            eprintln!(
222                "Didn't find theme '{}'. Defaulting.",
223                &self.config.syntax_theme
224            );
225            css_for_theme_with_class_style(
226                theme_set.themes.get("base16-ocean.dark").unwrap(),
227                ClassStyle::Spaced,
228            )
229            .unwrap()
230        }
231    }
232    // The main build orchestration function
233    pub async fn build(&self) -> Result<(), BuildError> {
234        let mut all_liquid_pages_map: HashMap<KString, LiquidGlobalsPage> = HashMap::new();
235        let mut convert_tasks = vec![];
236        let extra_css = self.get_css_for_theme();
237
238        for document_arc_mutex in self.documents.iter() {
239            let doc_arc_mutex_clone = Arc::clone(document_arc_mutex);
240            let config_arc = Arc::clone(&self.config);
241
242            convert_tasks.push(tokio::spawn(async move {
243                let doc_guard = doc_arc_mutex_clone.lock().await;
244                let route = route_from_path(
245                    config_arc.content_dir.clone().into(),
246                    doc_guard.at_path.clone().into(),
247                );
248                let liquid_page = LiquidGlobalsPage::from(&*doc_guard);
249
250                (KString::from(route), liquid_page)
251            }));
252        }
253
254        let converted_pages: Vec<Result<(KString, LiquidGlobalsPage), tokio::task::JoinError>> =
255            join_all(convert_tasks).await;
256
257        for result in converted_pages {
258            let (route, liquid_page) = result.map_err(|e| BuildError::JoinError(e.to_string()))?;
259            all_liquid_pages_map.insert(route, liquid_page);
260        }
261
262        let all_liquid_pages_map_arc = Arc::new(all_liquid_pages_map);
263
264        let templates_arc = Arc::new(self.templates.clone());
265        // TODO: I need to find a smarter way to do this, I thought Arc was multiple owner
266        // but across threads, I don't know man. Have to create a copy for every task?
267        let config_arc_copy = Arc::clone(&self.config.clone());
268        let partials_arc = Arc::new(self.partials.clone());
269
270        let mut tasks: Vec<JoinHandle<Result<Option<WritableFile>, BuildError>>> = vec![];
271
272        // Documents are going to stay here for now, at least until I realise a safe way
273        // to order tasks or have some kind of topological graph for tasks since they all
274        // require documents.
275        for document_arc_mutex in &self.documents {
276            let document_arc = Arc::clone(document_arc_mutex);
277
278            let all_liquid_pages_map_clone = Arc::clone(&all_liquid_pages_map_arc);
279            let mut globals = LiquidGlobals::new(
280                Arc::clone(&document_arc),
281                &all_liquid_pages_map_clone,
282                Arc::clone(&self.config),
283            )
284            .await;
285            globals.extra_css = extra_css.clone();
286
287            let templates = Arc::clone(&templates_arc);
288            let config = Arc::clone(&config_arc_copy);
289            let partials = Arc::clone(&partials_arc);
290
291            let doc_task = tokio::spawn(async move {
292                let md_renderer =
293                    MarkdownRenderer::new(document_arc, templates, config, partials.to_vec());
294
295                md_renderer.render(&mut globals, partials.to_vec()).await
296            });
297
298            tasks.push(doc_task);
299        }
300
301        tasks.extend(self.tasks.iter().map(|t| {
302            let t = Arc::clone(t);
303            let config = Arc::clone(&config_arc_copy);
304            let content = Arc::clone(&all_liquid_pages_map_arc);
305            tokio::spawn(async move { t.run(config, &content).await })
306        }));
307
308        let render_results: Vec<
309            Result<Result<Option<WritableFile>, BuildError>, tokio::task::JoinError>,
310        > = join_all(tasks).await; // Await all rendering tasks
311
312        // Process the results of all rendering tasks
313        for join_result in render_results {
314            match join_result {
315                Ok(render_result) => match render_result {
316                    Ok(writable_file_option) => match writable_file_option {
317                        Some(writable_file) => {
318                            if writable_file.path.as_os_str() != "" && writable_file.emit {
319                                self.write_result_to_system(writable_file).await?;
320                            }
321                        }
322                        None => continue,
323                    },
324                    Err(render_error) => {
325                        eprintln!("Rendering error: {}", render_error.red());
326                        return Err(render_error);
327                    }
328                },
329                Err(join_error) => {
330                    eprintln!("Task join error: {}", join_error.red());
331                    return Err(BuildError::JoinError(join_error.to_string()));
332                }
333            }
334        }
335
336        Ok(())
337    }
338}