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 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 content
208 .sections
209 .par_iter_mut()
210 .try_for_each(|(_, section)| {
211 let section_name = §ion.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 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 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 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 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 let preprocess_result = extract_encrypted_blocks(&post.content);
274
275 if preprocess_result.blocks.is_empty() {
276 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 let main_html = pipeline.process(&preprocess_result.markdown, &ctx);
286
287 let encrypted_blocks: Result<Vec<_>> = preprocess_result
289 .blocks
290 .par_iter()
291 .map(|block| {
292 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 let block_html = pipeline.process(&block.content, &ctx);
310
311 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 post.html = replace_placeholders(&main_html, &encrypted_blocks?, post.slug());
332 post.has_encrypted_blocks = true;
333 }
334 }
335
336 Ok(())
337 }
338
339 fn process_html_post(&self, post: &mut Post, templates: &Templates) -> Result<()> {
341 let rendered_html = templates.render_html_content(&self.config, post)?;
343
344 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 let preprocess_result = extract_html_encrypted_blocks(&rendered_html);
366
367 if preprocess_result.blocks.is_empty() {
368 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 let encrypted_blocks: Result<Vec<_>> = preprocess_result
378 .blocks
379 .iter()
380 .map(|block| {
381 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 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 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 debug!("Building link graph for backlinks");
432 let link_graph = LinkGraph::build(&self.config, content);
433 trace!("Link graph built");
434
435 if self.config.graph.enabled {
437 debug!("Generating graph visualization");
438 let graph_data = link_graph.to_graph_data();
439
440 let graph_json = serde_json::to_string(&graph_data)?;
442 fs::write(self.output_dir.join("graph.json"), graph_json)?;
443
444 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 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 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 content.sections.par_iter().try_for_each(|(_, section)| {
467 section.posts.par_iter().try_for_each(|post| {
468 let url = post.url(&self.config);
470 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 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 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) .filter(|post| {
503 !rss_config.exclude_encrypted_blocks || !post.has_encrypted_blocks
505 })
506 .collect();
507
508 posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
510
511 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 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 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 content
540 .sections
541 .par_iter()
542 .try_for_each(|(section_name, section)| {
543 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 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 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 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 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 pub fn incremental_build(&mut self, changes: &ChangeSet) -> Result<()> {
613 debug!("Starting incremental build");
614 trace!("Change set: {:?}", changes);
615
616 if changes.full_rebuild {
618 info!("Full rebuild required");
619 return self.build();
620 }
621
622 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 self.process_static_changes(changes)?;
632 return Ok(());
633 }
634
635 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 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 let content = self.process_content(content, &pipeline, &templates)?;
652
653 self.render_html(&content, &templates)?;
655
656 if self.config.text.enabled {
658 self.render_text(&content)?;
659 }
660
661 if changes.rebuild_css {
663 self.rebuild_css_only()?;
664 }
665
666 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 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 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 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 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 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 pub fn config(&self) -> &Config {
743 &self.config
744 }
745}