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
24pub 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 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 trace!("Stage 1: Cleaning output directory");
57 self.clean()?;
58
59 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 trace!("Stage 3: Processing assets");
74 self.process_assets()?;
75
76 trace!("Stage 4: Loading templates");
78 let templates = Templates::new(&self.resolve_path(&self.config.paths.templates))?;
79
80 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 trace!("Stage 6: Rendering HTML");
87 self.render_html(&content, &templates)?;
88
89 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 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 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 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 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 content
186 .sections
187 .par_iter_mut()
188 .try_for_each(|(_, section)| {
189 let section_name = §ion.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 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 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 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 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 let preprocess_result = extract_encrypted_blocks(&post.content);
252
253 if preprocess_result.blocks.is_empty() {
254 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 let main_html = pipeline.process(&preprocess_result.markdown, &ctx);
264
265 let encrypted_blocks: Result<Vec<_>> = preprocess_result
267 .blocks
268 .par_iter()
269 .map(|block| {
270 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 let block_html = pipeline.process(&block.content, &ctx);
288
289 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 post.html = replace_placeholders(&main_html, &encrypted_blocks?, post.slug());
310 post.has_encrypted_blocks = true;
311 }
312 }
313
314 Ok(())
315 }
316
317 fn process_html_post(&self, post: &mut Post, templates: &Templates) -> Result<()> {
319 let rendered_html = templates.render_html_content(&self.config, post)?;
321
322 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 let preprocess_result = extract_html_encrypted_blocks(&rendered_html);
344
345 if preprocess_result.blocks.is_empty() {
346 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 let encrypted_blocks: Result<Vec<_>> = preprocess_result
356 .blocks
357 .iter()
358 .map(|block| {
359 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 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 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 debug!("Building link graph for backlinks");
410 let link_graph = LinkGraph::build(&self.config, content);
411 trace!("Link graph built");
412
413 if self.config.graph.enabled {
415 debug!("Generating graph visualization");
416 let graph_data = link_graph.to_graph_data();
417
418 let graph_json = serde_json::to_string(&graph_data)?;
420 fs::write(self.output_dir.join("graph.json"), graph_json)?;
421
422 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 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 content.sections.par_iter().try_for_each(|(_, section)| {
437 section.posts.par_iter().try_for_each(|post| {
438 let url = post.url(&self.config);
440 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 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 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) .filter(|post| {
473 !rss_config.exclude_encrypted_blocks || !post.has_encrypted_blocks
475 })
476 .collect();
477
478 posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
480
481 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 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 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 content
510 .sections
511 .par_iter()
512 .try_for_each(|(section_name, section)| {
513 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 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 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 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 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 pub fn incremental_build(&mut self, changes: &ChangeSet) -> Result<()> {
583 debug!("Starting incremental build");
584 trace!("Change set: {:?}", changes);
585
586 if changes.full_rebuild {
588 info!("Full rebuild required");
589 return self.build();
590 }
591
592 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 self.process_static_changes(changes)?;
602 return Ok(());
603 }
604
605 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 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 let content = self.process_content(content, &pipeline, &templates)?;
622
623 self.render_html(&content, &templates)?;
625
626 if self.config.text.enabled {
628 self.render_text(&content)?;
629 }
630
631 if changes.rebuild_css {
633 self.rebuild_css_only()?;
634 }
635
636 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 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 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 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 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 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 pub fn config(&self) -> &Config {
713 &self.config
714 }
715}