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