rs_web/
build.rs

1use anyhow::{Context, Result};
2use log::{debug, info, trace};
3use rayon::prelude::*;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::assets::{
8    ImageConfig, build_css, copy_single_static_file, copy_static_files, optimize_images,
9    optimize_single_image,
10};
11use crate::config::Config;
12use crate::content::{Content, ContentType, Post, discover_content};
13use crate::encryption::{encrypt_content, resolve_password};
14use crate::links::LinkGraph;
15use crate::markdown::{
16    Pipeline, TransformContext, extract_encrypted_blocks, extract_html_encrypted_blocks,
17    replace_placeholders,
18};
19use crate::rss::generate_rss;
20use crate::templates::Templates;
21use crate::text::{format_home_text, format_post_text};
22use crate::watch::ChangeSet;
23
24/// Main build orchestrator
25pub struct Builder {
26    config: Config,
27    output_dir: PathBuf,
28    project_dir: PathBuf,
29}
30
31impl Builder {
32    pub fn new(config: Config, output_dir: PathBuf, project_dir: PathBuf) -> Self {
33        Self {
34            config,
35            output_dir,
36            project_dir,
37        }
38    }
39
40    /// Resolve a path relative to the project directory
41    fn resolve_path(&self, path: &str) -> PathBuf {
42        let p = Path::new(path);
43        if p.is_absolute() {
44            p.to_path_buf()
45        } else {
46            self.project_dir.join(path)
47        }
48    }
49
50    pub fn build(&mut self) -> Result<()> {
51        info!("Starting build");
52        debug!("Output directory: {:?}", self.output_dir);
53        debug!("Project directory: {:?}", self.project_dir);
54
55        // Stage 1: Clean output directory
56        trace!("Stage 1: Cleaning output directory");
57        self.clean()?;
58
59        // Stage 2: Discover and load content
60        trace!("Stage 2: Discovering content");
61        let content = self.load_content()?;
62        debug!(
63            "Found {} sections with {} total posts",
64            content.sections.len(),
65            content
66                .sections
67                .values()
68                .map(|s| s.posts.len())
69                .sum::<usize>()
70        );
71
72        // Stage 3: Process assets
73        trace!("Stage 3: Processing assets");
74        self.process_assets()?;
75
76        // Stage 4: Load templates (needed for HTML content processing)
77        trace!("Stage 4: Loading templates");
78        let templates = Templates::new(&self.resolve_path(&self.config.paths.templates))?;
79
80        // Stage 5: Process content through pipeline (markdown) or Tera (HTML)
81        trace!("Stage 5: Processing content through pipeline");
82        let pipeline = Pipeline::from_config(&self.config);
83        let content = self.process_content(content, &pipeline, &templates)?;
84
85        // Stage 6: Render and write HTML
86        trace!("Stage 6: Rendering HTML");
87        self.render_html(&content, &templates)?;
88
89        // Stage 7: Render text output (if enabled)
90        if self.config.text.enabled {
91            trace!("Stage 7: Rendering text output");
92            self.render_text(&content)?;
93        }
94
95        let total_posts: usize = content.sections.values().map(|s| s.posts.len()).sum();
96        info!(
97            "Build complete: {} posts in {} sections",
98            total_posts,
99            content.sections.len()
100        );
101        println!(
102            "Generated {} posts in {} sections",
103            total_posts,
104            content.sections.len()
105        );
106
107        Ok(())
108    }
109
110    fn clean(&self) -> Result<()> {
111        if self.output_dir.exists() {
112            debug!("Removing existing output directory: {:?}", self.output_dir);
113            fs::remove_dir_all(&self.output_dir).with_context(|| {
114                format!("Failed to clean output directory: {:?}", self.output_dir)
115            })?;
116        }
117        trace!("Creating output directories");
118        fs::create_dir_all(&self.output_dir)?;
119        fs::create_dir_all(self.output_dir.join("static"))?;
120        Ok(())
121    }
122
123    fn load_content(&self) -> Result<Content> {
124        discover_content(&self.config.paths, Some(&self.project_dir))
125    }
126
127    fn process_assets(&self) -> Result<()> {
128        let static_dir = self.output_dir.join("static");
129        let paths = &self.config.paths;
130
131        // Build CSS
132        debug!("Building CSS from {:?}", self.resolve_path(&paths.styles));
133        build_css(
134            &self.resolve_path(&paths.styles),
135            &static_dir.join(&self.config.build.css_output),
136            self.config.build.minify_css,
137        )?;
138
139        // Optimize images
140        debug!(
141            "Optimizing images (quality: {}, scale: {})",
142            self.config.images.quality, self.config.images.scale_factor
143        );
144        let image_config = ImageConfig {
145            quality: self.config.images.quality,
146            scale_factor: self.config.images.scale_factor,
147        };
148        optimize_images(
149            &self.resolve_path(&paths.static_files),
150            &static_dir,
151            &image_config,
152        )?;
153
154        // Copy other static files
155        debug!(
156            "Copying static files from {:?}",
157            self.resolve_path(&paths.static_files)
158        );
159        copy_static_files(&self.resolve_path(&paths.static_files), &static_dir)?;
160
161        Ok(())
162    }
163
164    fn process_content(
165        &self,
166        mut content: Content,
167        pipeline: &Pipeline,
168        templates: &Templates,
169    ) -> Result<Content> {
170        let paths = &self.config.paths;
171
172        // Process home page
173        if let Some(page) = content.home.take() {
174            let home_path = self.resolve_path(&paths.content).join(&paths.home);
175            let ctx = TransformContext {
176                config: &self.config,
177                current_path: &home_path,
178                base_url: &self.config.site.base_url,
179            };
180            let html = pipeline.process(&page.content, &ctx);
181            content.home = Some(page.with_html(html));
182        }
183
184        // Process all posts
185        content
186            .sections
187            .par_iter_mut()
188            .try_for_each(|(_, section)| {
189                let section_name = &section.name;
190                section.posts.par_iter_mut().try_for_each(|post| {
191                    self.process_single_post(post, section_name, pipeline, paths, templates)
192                })
193            })?;
194
195        Ok(content)
196    }
197
198    /// Process a single post through the markdown pipeline (or Tera for HTML) and encryption
199    fn process_single_post(
200        &self,
201        post: &mut crate::content::Post,
202        section_name: &str,
203        pipeline: &Pipeline,
204        paths: &crate::config::PathsConfig,
205        templates: &Templates,
206    ) -> Result<()> {
207        trace!(
208            "Processing post: {} ({})",
209            post.frontmatter.title, section_name
210        );
211
212        // Handle HTML content files - process through Tera
213        if post.content_type == ContentType::Html {
214            trace!("Post is HTML content, processing through Tera");
215            return self.process_html_post(post, templates);
216        }
217
218        // Markdown processing
219        let path = self
220            .resolve_path(&paths.content)
221            .join(section_name)
222            .join(format!("{}.md", post.file_slug));
223        let ctx = TransformContext {
224            config: &self.config,
225            current_path: &path,
226            base_url: &self.config.site.base_url,
227        };
228
229        // Check if post should be fully encrypted
230        if post.frontmatter.encrypted {
231            debug!("Encrypting post: {}", post.frontmatter.title);
232            let html = pipeline.process(&post.content, &ctx);
233            let password = resolve_password(
234                &self.config.encryption,
235                post.frontmatter.password.as_deref(),
236            )
237            .with_context(|| {
238                format!(
239                    "Failed to resolve password for encrypted post: {}",
240                    post.frontmatter.title
241                )
242            })?;
243
244            let encrypted = encrypt_content(&html, &password)
245                .with_context(|| format!("Failed to encrypt post: {}", post.frontmatter.title))?;
246
247            post.encrypted_content = Some(encrypted);
248            post.html = String::new();
249        } else {
250            // Check for partial encryption (:::encrypted blocks)
251            let preprocess_result = extract_encrypted_blocks(&post.content);
252
253            if preprocess_result.blocks.is_empty() {
254                // No encrypted blocks, process normally
255                post.html = pipeline.process(&post.content, &ctx);
256            } else {
257                debug!(
258                    "Found {} encrypted blocks in post: {}",
259                    preprocess_result.blocks.len(),
260                    post.frontmatter.title
261                );
262                // Process main content with placeholders
263                let main_html = pipeline.process(&preprocess_result.markdown, &ctx);
264
265                // Process and encrypt each block
266                let encrypted_blocks: Result<Vec<_>> = preprocess_result
267                    .blocks
268                    .par_iter()
269                    .map(|block| {
270                        // Use block-specific password if provided, otherwise fall back to global
271                        let block_password = if let Some(ref pw) = block.password {
272                            pw.clone()
273                        } else {
274                            resolve_password(
275                                &self.config.encryption,
276                                post.frontmatter.password.as_deref(),
277                            )
278                            .with_context(|| {
279                                format!(
280                                    "Failed to resolve password for block {} in post: {}",
281                                    block.id, post.frontmatter.title
282                                )
283                            })?
284                        };
285
286                        // Render block content through pipeline
287                        let block_html = pipeline.process(&block.content, &ctx);
288
289                        // Encrypt the rendered HTML
290                        let encrypted = encrypt_content(&block_html, &block_password)
291                            .with_context(|| {
292                                format!(
293                                    "Failed to encrypt block {} in post: {}",
294                                    block.id, post.frontmatter.title
295                                )
296                            })?;
297
298                        Ok((
299                            block.id,
300                            encrypted.ciphertext,
301                            encrypted.salt,
302                            encrypted.nonce,
303                            block.password.is_some(),
304                        ))
305                    })
306                    .collect();
307
308                // Replace placeholders with encrypted HTML
309                post.html = replace_placeholders(&main_html, &encrypted_blocks?, post.slug());
310                post.has_encrypted_blocks = true;
311            }
312        }
313
314        Ok(())
315    }
316
317    /// Process an HTML content file through Tera templating with encryption support
318    fn process_html_post(&self, post: &mut Post, templates: &Templates) -> Result<()> {
319        // Render content through Tera first
320        let rendered_html = templates.render_html_content(&self.config, post)?;
321
322        // Check if post should be fully encrypted
323        if post.frontmatter.encrypted {
324            let password = resolve_password(
325                &self.config.encryption,
326                post.frontmatter.password.as_deref(),
327            )
328            .with_context(|| {
329                format!(
330                    "Failed to resolve password for encrypted HTML post: {}",
331                    post.frontmatter.title
332                )
333            })?;
334
335            let encrypted = encrypt_content(&rendered_html, &password).with_context(|| {
336                format!("Failed to encrypt HTML post: {}", post.frontmatter.title)
337            })?;
338
339            post.encrypted_content = Some(encrypted);
340            post.html = String::new();
341        } else {
342            // Check for partial encryption (<encrypted> blocks)
343            let preprocess_result = extract_html_encrypted_blocks(&rendered_html);
344
345            if preprocess_result.blocks.is_empty() {
346                // No encrypted blocks, use rendered HTML as-is
347                post.html = rendered_html;
348            } else {
349                debug!(
350                    "Found {} encrypted blocks in HTML post: {}",
351                    preprocess_result.blocks.len(),
352                    post.frontmatter.title
353                );
354                // Process and encrypt each block
355                let encrypted_blocks: Result<Vec<_>> = preprocess_result
356                    .blocks
357                    .iter()
358                    .map(|block| {
359                        // Use block-specific password if provided, otherwise fall back to global
360                        let block_password = if let Some(ref pw) = block.password {
361                            pw.clone()
362                        } else {
363                            resolve_password(
364                                &self.config.encryption,
365                                post.frontmatter.password.as_deref(),
366                            )
367                            .with_context(|| {
368                                format!(
369                                    "Failed to resolve password for block {} in HTML post: {}",
370                                    block.id, post.frontmatter.title
371                                )
372                            })?
373                        };
374
375                        // Encrypt the block content (already rendered through Tera)
376                        let encrypted = encrypt_content(&block.content, &block_password)
377                            .with_context(|| {
378                                format!(
379                                    "Failed to encrypt block {} in HTML post: {}",
380                                    block.id, post.frontmatter.title
381                                )
382                            })?;
383
384                        Ok((
385                            block.id,
386                            encrypted.ciphertext,
387                            encrypted.salt,
388                            encrypted.nonce,
389                            block.password.is_some(),
390                        ))
391                    })
392                    .collect();
393
394                // Replace placeholders with encrypted HTML
395                post.html = replace_placeholders(
396                    &preprocess_result.markdown,
397                    &encrypted_blocks?,
398                    post.slug(),
399                );
400                post.has_encrypted_blocks = true;
401            }
402        }
403
404        Ok(())
405    }
406
407    fn render_html(&self, content: &Content, templates: &Templates) -> Result<()> {
408        // Build link graph for backlinks
409        debug!("Building link graph for backlinks");
410        let link_graph = LinkGraph::build(&self.config, content);
411        trace!("Link graph built");
412
413        // Generate graph if enabled
414        if self.config.graph.enabled {
415            debug!("Generating graph visualization");
416            let graph_data = link_graph.to_graph_data();
417
418            // Write graph.json for visualization
419            let graph_json = serde_json::to_string(&graph_data)?;
420            fs::write(self.output_dir.join("graph.json"), graph_json)?;
421
422            // Render graph page
423            let graph_dir = self.output_dir.join(&self.config.graph.path);
424            fs::create_dir_all(&graph_dir)?;
425            let graph_html = templates.render_graph(&self.config, &graph_data)?;
426            fs::write(graph_dir.join("index.html"), graph_html)?;
427        }
428
429        // Render home page
430        if let Some(home_page) = &content.home {
431            let html = templates.render_home(&self.config, home_page, content)?;
432            fs::write(self.output_dir.join("index.html"), html)?;
433        }
434
435        // Render posts for each section
436        content.sections.par_iter().try_for_each(|(_, section)| {
437            section.posts.par_iter().try_for_each(|post| {
438                // Use resolved URL to determine output path
439                let url = post.url(&self.config);
440                // Convert URL to file path: /blog/2024/01/hello/ -> blog/2024/01/hello
441                let relative_path = url.trim_matches('/');
442                let post_dir = self.output_dir.join(relative_path);
443                fs::create_dir_all(&post_dir)?;
444                let html = templates.render_post(&self.config, post, &link_graph)?;
445                fs::write(post_dir.join("index.html"), html)?;
446                Ok::<_, anyhow::Error>(())
447            })
448        })?;
449
450        // Generate RSS feed
451        if self.config.rss.enabled {
452            debug!("Generating RSS feed");
453            self.generate_rss(content)?;
454        }
455
456        Ok(())
457    }
458
459    fn generate_rss(&self, content: &Content) -> Result<()> {
460        trace!("Building RSS feed");
461        let rss_config = &self.config.rss;
462
463        // Collect posts from specified sections (or all if empty)
464        let mut posts: Vec<&Post> = content
465            .sections
466            .iter()
467            .filter(|(name, _)| {
468                rss_config.sections.is_empty() || rss_config.sections.contains(name)
469            })
470            .flat_map(|(_, section)| section.posts.iter())
471            .filter(|post| !post.frontmatter.encrypted) // Exclude fully encrypted posts
472            .filter(|post| {
473                // Optionally exclude posts with encrypted blocks
474                !rss_config.exclude_encrypted_blocks || !post.has_encrypted_blocks
475            })
476            .collect();
477
478        // Sort by date (newest first)
479        posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
480
481        // Limit number of items
482        posts.truncate(rss_config.limit);
483
484        let rss_xml = generate_rss(&self.config, &posts);
485        fs::write(self.output_dir.join(&rss_config.filename), rss_xml)?;
486
487        Ok(())
488    }
489
490    /// Generate plain text versions of posts for curl-friendly access
491    fn render_text(&self, content: &Content) -> Result<()> {
492        let text_config = &self.config.text;
493        let base_url = &self.config.site.base_url;
494
495        // Render home page text if enabled
496        if text_config.include_home
497            && let Some(home_page) = &content.home
498        {
499            let text = format_home_text(
500                &self.config.site.title,
501                &self.config.site.description,
502                &home_page.html,
503                base_url,
504            );
505            fs::write(self.output_dir.join("index.txt"), text)?;
506        }
507
508        // Render posts for each section in parallel
509        content
510            .sections
511            .par_iter()
512            .try_for_each(|(section_name, section)| {
513                // Check if this section should be included
514                if !text_config.sections.is_empty() && !text_config.sections.contains(section_name)
515                {
516                    return Ok::<_, anyhow::Error>(());
517                }
518
519                section.posts.par_iter().try_for_each(|post| {
520                    // Skip encrypted posts if configured
521                    if text_config.exclude_encrypted
522                        && (post.frontmatter.encrypted || post.has_encrypted_blocks)
523                    {
524                        return Ok::<_, anyhow::Error>(());
525                    }
526
527                    let url = post.url(&self.config);
528                    let relative_path = url.trim_matches('/');
529                    let post_dir = self.output_dir.join(relative_path);
530
531                    // Format date for display
532                    let date_str = post
533                        .frontmatter
534                        .date
535                        .map(|d| d.format("%Y-%m-%d").to_string());
536
537                    let tags = post.frontmatter.tags.as_deref().unwrap_or(&[]);
538
539                    // For fully encrypted posts, use placeholder content
540                    let content = if post.frontmatter.encrypted {
541                        "[This post is encrypted - visit web version to decrypt]"
542                    } else {
543                        &post.html
544                    };
545
546                    let text = format_post_text(
547                        &post.frontmatter.title,
548                        date_str.as_deref(),
549                        post.frontmatter.description.as_deref(),
550                        tags,
551                        post.reading_time,
552                        content,
553                        &url,
554                        base_url,
555                    );
556
557                    fs::write(post_dir.join("index.txt"), text)?;
558                    Ok::<_, anyhow::Error>(())
559                })
560            })?;
561
562        // Count text files generated
563        let text_count: usize = content
564            .sections
565            .iter()
566            .filter(|(name, _)| {
567                text_config.sections.is_empty() || text_config.sections.contains(name)
568            })
569            .flat_map(|(_, section)| section.posts.iter())
570            .filter(|post| {
571                !text_config.exclude_encrypted
572                    || (!post.frontmatter.encrypted && !post.has_encrypted_blocks)
573            })
574            .count();
575
576        println!("Generated {} text files", text_count);
577
578        Ok(())
579    }
580
581    /// Perform an incremental build based on what changed
582    pub fn incremental_build(&mut self, changes: &ChangeSet) -> Result<()> {
583        debug!("Starting incremental build");
584        trace!("Change set: {:?}", changes);
585
586        // If full rebuild is needed, just do a regular build
587        if changes.full_rebuild {
588            info!("Full rebuild required");
589            return self.build();
590        }
591
592        // Handle CSS-only changes (fastest path)
593        if changes.rebuild_css
594            && !changes.reload_templates
595            && !changes.rebuild_home
596            && changes.content_files.is_empty()
597        {
598            self.rebuild_css_only()?;
599
600            // Also handle any static/image changes
601            self.process_static_changes(changes)?;
602            return Ok(());
603        }
604
605        // Handle static file changes without content rebuild
606        if !changes.reload_templates
607            && !changes.rebuild_home
608            && changes.content_files.is_empty()
609            && !changes.rebuild_css
610        {
611            self.process_static_changes(changes)?;
612            return Ok(());
613        }
614
615        // For template or content changes, we need to rebuild content
616        let content = self.load_content()?;
617        let templates = Templates::new(&self.resolve_path(&self.config.paths.templates))?;
618        let pipeline = Pipeline::from_config(&self.config);
619
620        // Process all content (could be optimized further for single-file changes)
621        let content = self.process_content(content, &pipeline, &templates)?;
622
623        // Render HTML
624        self.render_html(&content, &templates)?;
625
626        // Render text if enabled
627        if self.config.text.enabled {
628            self.render_text(&content)?;
629        }
630
631        // Handle any CSS changes
632        if changes.rebuild_css {
633            self.rebuild_css_only()?;
634        }
635
636        // Handle static/image changes
637        self.process_static_changes(changes)?;
638
639        let total_posts: usize = content.sections.values().map(|s| s.posts.len()).sum();
640        println!(
641            "Rebuilt {} posts in {} sections",
642            total_posts,
643            content.sections.len()
644        );
645
646        Ok(())
647    }
648
649    /// Rebuild only CSS
650    fn rebuild_css_only(&self) -> Result<()> {
651        let static_dir = self.output_dir.join("static");
652        build_css(
653            &self.resolve_path(&self.config.paths.styles),
654            &static_dir.join(&self.config.build.css_output),
655            self.config.build.minify_css,
656        )?;
657        println!("Rebuilt CSS");
658        Ok(())
659    }
660
661    /// Process static file and image changes
662    fn process_static_changes(&self, changes: &ChangeSet) -> Result<()> {
663        let static_dir = self.output_dir.join("static");
664        let source_static = self.resolve_path(&self.config.paths.static_files);
665
666        let image_config = ImageConfig {
667            quality: self.config.images.quality,
668            scale_factor: self.config.images.scale_factor,
669        };
670
671        // Process changed images
672        for rel_path in &changes.image_files {
673            let src = source_static.join(rel_path.as_path());
674            let dest = static_dir.join(rel_path.as_path());
675
676            if src.exists() {
677                if let Some(parent) = dest.parent() {
678                    fs::create_dir_all(parent)?;
679                }
680                optimize_single_image(&src, &dest, &image_config)?;
681                println!("Optimized image: {}", rel_path.display());
682            }
683        }
684
685        // Process changed static files
686        for rel_path in &changes.static_files {
687            let src = source_static.join(rel_path.as_path());
688            let dest = static_dir.join(rel_path.as_path());
689
690            if src.exists() {
691                if let Some(parent) = dest.parent() {
692                    fs::create_dir_all(parent)?;
693                }
694                copy_single_static_file(&src, &dest)?;
695                println!("Copied static file: {}", rel_path.display());
696            }
697        }
698
699        Ok(())
700    }
701
702    /// Reload config from disk
703    pub fn reload_config(&mut self) -> Result<()> {
704        let config_path = self.project_dir.join("config.toml");
705        debug!("Reloading config from {:?}", config_path);
706        self.config = crate::config::Config::load(&config_path)?;
707        info!("Config reloaded successfully");
708        Ok(())
709    }
710
711    /// Get a reference to the current config
712    pub fn config(&self) -> &Config {
713        &self.config
714    }
715}