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