rs_web/
build.rs

1use anyhow::{Context, Result};
2use rayon::prelude::*;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::assets::{ImageConfig, build_css, copy_static_files, optimize_images};
7use crate::config::Config;
8use crate::content::{Content, ContentType, Post, discover_content};
9use crate::encryption::{encrypt_content, resolve_password};
10use crate::links::LinkGraph;
11use crate::markdown::{
12    Pipeline, TransformContext, extract_encrypted_blocks, extract_html_encrypted_blocks,
13    replace_placeholders,
14};
15use crate::rss::generate_rss;
16use crate::templates::Templates;
17
18/// Main build orchestrator
19pub struct Builder {
20    config: Config,
21    output_dir: PathBuf,
22}
23
24impl Builder {
25    pub fn new(config: Config, output_dir: PathBuf) -> Self {
26        Self { config, output_dir }
27    }
28
29    pub fn build(&mut self) -> Result<()> {
30        // Stage 1: Clean output directory
31        self.clean()?;
32
33        // Stage 2: Discover and load content
34        let content = self.load_content()?;
35
36        // Stage 3: Process assets
37        self.process_assets()?;
38
39        // Stage 4: Load templates (needed for HTML content processing)
40        let templates = Templates::new(&self.config.paths.templates)?;
41
42        // Stage 5: Process content through pipeline (markdown) or Tera (HTML)
43        let pipeline = Pipeline::from_config(&self.config);
44        let content = self.process_content(content, &pipeline, &templates)?;
45
46        // Stage 6: Render and write HTML
47        self.render_html(&content, &templates)?;
48
49        let total_posts: usize = content.sections.values().map(|s| s.posts.len()).sum();
50        println!(
51            "Generated {} posts in {} sections",
52            total_posts,
53            content.sections.len()
54        );
55
56        Ok(())
57    }
58
59    fn clean(&self) -> Result<()> {
60        if self.output_dir.exists() {
61            fs::remove_dir_all(&self.output_dir).with_context(|| {
62                format!("Failed to clean output directory: {:?}", self.output_dir)
63            })?;
64        }
65        fs::create_dir_all(&self.output_dir)?;
66        fs::create_dir_all(self.output_dir.join("static"))?;
67        Ok(())
68    }
69
70    fn load_content(&self) -> Result<Content> {
71        discover_content(&self.config.paths)
72    }
73
74    fn process_assets(&self) -> Result<()> {
75        let static_dir = self.output_dir.join("static");
76        let paths = &self.config.paths;
77
78        // Build CSS
79        build_css(
80            Path::new(&paths.styles),
81            &static_dir.join("rs.css"),
82            self.config.build.minify_css,
83        )?;
84
85        // Optimize images
86        let image_config = ImageConfig {
87            quality: self.config.images.quality,
88            scale_factor: self.config.images.scale_factor,
89        };
90        optimize_images(Path::new(&paths.static_files), &static_dir, &image_config)?;
91
92        // Copy other static files
93        copy_static_files(Path::new(&paths.static_files), &static_dir)?;
94
95        Ok(())
96    }
97
98    fn process_content(
99        &self,
100        mut content: Content,
101        pipeline: &Pipeline,
102        templates: &Templates,
103    ) -> Result<Content> {
104        let paths = &self.config.paths;
105
106        // Process home page
107        if let Some(page) = content.home.take() {
108            let home_path = format!("{}/{}", paths.content, paths.home);
109            let ctx = TransformContext {
110                config: &self.config,
111                current_path: Path::new(&home_path),
112                base_url: &self.config.site.base_url,
113            };
114            let html = pipeline.process(&page.content, &ctx);
115            content.home = Some(page.with_html(html));
116        }
117
118        // Process all posts
119        content
120            .sections
121            .par_iter_mut()
122            .try_for_each(|(_, section)| {
123                let section_name = &section.name;
124                section.posts.par_iter_mut().try_for_each(|post| {
125                    self.process_single_post(post, section_name, pipeline, paths, templates)
126                })
127            })?;
128
129        Ok(content)
130    }
131
132    /// Process a single post through the markdown pipeline (or Tera for HTML) and encryption
133    fn process_single_post(
134        &self,
135        post: &mut crate::content::Post,
136        section_name: &str,
137        pipeline: &Pipeline,
138        paths: &crate::config::PathsConfig,
139        templates: &Templates,
140    ) -> Result<()> {
141        // Handle HTML content files - process through Tera
142        if post.content_type == ContentType::Html {
143            return self.process_html_post(post, templates);
144        }
145
146        // Markdown processing
147        let path_str = format!("{}/{}/{}.md", paths.content, section_name, post.file_slug);
148        let path = PathBuf::from(&path_str);
149        let ctx = TransformContext {
150            config: &self.config,
151            current_path: &path,
152            base_url: &self.config.site.base_url,
153        };
154
155        // Check if post should be fully encrypted
156        if post.frontmatter.encrypted {
157            let html = pipeline.process(&post.content, &ctx);
158            let password = resolve_password(
159                &self.config.encryption,
160                post.frontmatter.password.as_deref(),
161            )
162            .with_context(|| {
163                format!(
164                    "Failed to resolve password for encrypted post: {}",
165                    post.frontmatter.title
166                )
167            })?;
168
169            let encrypted = encrypt_content(&html, &password)
170                .with_context(|| format!("Failed to encrypt post: {}", post.frontmatter.title))?;
171
172            post.encrypted_content = Some(encrypted);
173            post.html = String::new();
174        } else {
175            // Check for partial encryption (:::encrypted blocks)
176            let preprocess_result = extract_encrypted_blocks(&post.content);
177
178            if preprocess_result.blocks.is_empty() {
179                // No encrypted blocks, process normally
180                post.html = pipeline.process(&post.content, &ctx);
181            } else {
182                // Process main content with placeholders
183                let main_html = pipeline.process(&preprocess_result.markdown, &ctx);
184
185                // Process and encrypt each block
186                let encrypted_blocks: Result<Vec<_>> = preprocess_result
187                    .blocks
188                    .par_iter()
189                    .map(|block| {
190                        // Use block-specific password if provided, otherwise fall back to global
191                        let block_password = if let Some(ref pw) = block.password {
192                            pw.clone()
193                        } else {
194                            resolve_password(
195                                &self.config.encryption,
196                                post.frontmatter.password.as_deref(),
197                            )
198                            .with_context(|| {
199                                format!(
200                                    "Failed to resolve password for block {} in post: {}",
201                                    block.id, post.frontmatter.title
202                                )
203                            })?
204                        };
205
206                        // Render block content through pipeline
207                        let block_html = pipeline.process(&block.content, &ctx);
208
209                        // Encrypt the rendered HTML
210                        let encrypted = encrypt_content(&block_html, &block_password)
211                            .with_context(|| {
212                                format!(
213                                    "Failed to encrypt block {} in post: {}",
214                                    block.id, post.frontmatter.title
215                                )
216                            })?;
217
218                        Ok((
219                            block.id,
220                            encrypted.ciphertext,
221                            encrypted.salt,
222                            encrypted.nonce,
223                            block.password.is_some(),
224                        ))
225                    })
226                    .collect();
227
228                // Replace placeholders with encrypted HTML
229                post.html = replace_placeholders(&main_html, &encrypted_blocks?, post.slug());
230                post.has_encrypted_blocks = true;
231            }
232        }
233
234        Ok(())
235    }
236
237    /// Process an HTML content file through Tera templating with encryption support
238    fn process_html_post(&self, post: &mut Post, templates: &Templates) -> Result<()> {
239        // Render content through Tera first
240        let rendered_html = templates.render_html_content(&self.config, post)?;
241
242        // Check if post should be fully encrypted
243        if post.frontmatter.encrypted {
244            let password = resolve_password(
245                &self.config.encryption,
246                post.frontmatter.password.as_deref(),
247            )
248            .with_context(|| {
249                format!(
250                    "Failed to resolve password for encrypted HTML post: {}",
251                    post.frontmatter.title
252                )
253            })?;
254
255            let encrypted = encrypt_content(&rendered_html, &password).with_context(|| {
256                format!("Failed to encrypt HTML post: {}", post.frontmatter.title)
257            })?;
258
259            post.encrypted_content = Some(encrypted);
260            post.html = String::new();
261        } else {
262            // Check for partial encryption (<encrypted> blocks)
263            let preprocess_result = extract_html_encrypted_blocks(&rendered_html);
264
265            if preprocess_result.blocks.is_empty() {
266                // No encrypted blocks, use rendered HTML as-is
267                post.html = rendered_html;
268            } else {
269                // Process and encrypt each block
270                let encrypted_blocks: Result<Vec<_>> = preprocess_result
271                    .blocks
272                    .iter()
273                    .map(|block| {
274                        // Use block-specific password if provided, otherwise fall back to global
275                        let block_password = if let Some(ref pw) = block.password {
276                            pw.clone()
277                        } else {
278                            resolve_password(
279                                &self.config.encryption,
280                                post.frontmatter.password.as_deref(),
281                            )
282                            .with_context(|| {
283                                format!(
284                                    "Failed to resolve password for block {} in HTML post: {}",
285                                    block.id, post.frontmatter.title
286                                )
287                            })?
288                        };
289
290                        // Encrypt the block content (already rendered through Tera)
291                        let encrypted = encrypt_content(&block.content, &block_password)
292                            .with_context(|| {
293                                format!(
294                                    "Failed to encrypt block {} in HTML post: {}",
295                                    block.id, post.frontmatter.title
296                                )
297                            })?;
298
299                        Ok((
300                            block.id,
301                            encrypted.ciphertext,
302                            encrypted.salt,
303                            encrypted.nonce,
304                            block.password.is_some(),
305                        ))
306                    })
307                    .collect();
308
309                // Replace placeholders with encrypted HTML
310                post.html = replace_placeholders(
311                    &preprocess_result.markdown,
312                    &encrypted_blocks?,
313                    post.slug(),
314                );
315                post.has_encrypted_blocks = true;
316            }
317        }
318
319        Ok(())
320    }
321
322    fn render_html(&self, content: &Content, templates: &Templates) -> Result<()> {
323        // Build link graph for backlinks
324        let link_graph = LinkGraph::build(&self.config, content);
325
326        // Generate graph if enabled
327        if self.config.graph.enabled {
328            let graph_data = link_graph.to_graph_data();
329
330            // Write graph.json for visualization
331            let graph_json = serde_json::to_string(&graph_data)?;
332            fs::write(self.output_dir.join("graph.json"), graph_json)?;
333
334            // Render graph page
335            let graph_dir = self.output_dir.join(&self.config.graph.path);
336            fs::create_dir_all(&graph_dir)?;
337            let graph_html = templates.render_graph(&self.config, &graph_data)?;
338            fs::write(graph_dir.join("index.html"), graph_html)?;
339        }
340
341        // Render home page
342        if let Some(home_page) = &content.home {
343            let html = templates.render_home(&self.config, home_page, content)?;
344            fs::write(self.output_dir.join("index.html"), html)?;
345        }
346
347        // Render posts for each section
348        content.sections.par_iter().try_for_each(|(_, section)| {
349            section.posts.par_iter().try_for_each(|post| {
350                // Use resolved URL to determine output path
351                let url = post.url(&self.config);
352                // Convert URL to file path: /blog/2024/01/hello/ -> blog/2024/01/hello
353                let relative_path = url.trim_matches('/');
354                let post_dir = self.output_dir.join(relative_path);
355                fs::create_dir_all(&post_dir)?;
356                let html = templates.render_post(&self.config, post, &link_graph)?;
357                fs::write(post_dir.join("index.html"), html)?;
358                Ok::<_, anyhow::Error>(())
359            })
360        })?;
361
362        // Generate RSS feed
363        if self.config.rss.enabled {
364            self.generate_rss(content)?;
365        }
366
367        Ok(())
368    }
369
370    fn generate_rss(&self, content: &Content) -> Result<()> {
371        let rss_config = &self.config.rss;
372
373        // Collect posts from specified sections (or all if empty)
374        let mut posts: Vec<&Post> = content
375            .sections
376            .iter()
377            .filter(|(name, _)| {
378                rss_config.sections.is_empty() || rss_config.sections.contains(name)
379            })
380            .flat_map(|(_, section)| section.posts.iter())
381            .filter(|post| !post.frontmatter.encrypted) // Exclude fully encrypted posts
382            .filter(|post| {
383                // Optionally exclude posts with encrypted blocks
384                !rss_config.exclude_encrypted_blocks || !post.has_encrypted_blocks
385            })
386            .collect();
387
388        // Sort by date (newest first)
389        posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
390
391        // Limit number of items
392        posts.truncate(rss_config.limit);
393
394        let rss_xml = generate_rss(&self.config, &posts);
395        fs::write(self.output_dir.join(&rss_config.filename), rss_xml)?;
396
397        Ok(())
398    }
399}