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(
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 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 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 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 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 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 content
212 .sections
213 .par_iter_mut()
214 .try_for_each(|(_, section)| {
215 let section_name = §ion.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 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 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 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 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 let preprocess_result = extract_encrypted_blocks(&post.content);
278
279 if preprocess_result.blocks.is_empty() {
280 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 let main_html = pipeline.process(&preprocess_result.markdown, &ctx);
290
291 let encrypted_blocks: Result<Vec<_>> = preprocess_result
293 .blocks
294 .par_iter()
295 .map(|block| {
296 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 let block_html = pipeline.process(&block.content, &ctx);
314
315 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 post.html = replace_placeholders(&main_html, &encrypted_blocks?, post.slug());
336 post.has_encrypted_blocks = true;
337 }
338 }
339
340 Ok(())
341 }
342
343 fn process_html_post(&self, post: &mut Post, templates: &Templates) -> Result<()> {
345 let rendered_html = templates.render_html_content(&self.config, post)?;
347
348 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 let preprocess_result = extract_html_encrypted_blocks(&rendered_html);
370
371 if preprocess_result.blocks.is_empty() {
372 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 let encrypted_blocks: Result<Vec<_>> = preprocess_result
382 .blocks
383 .iter()
384 .map(|block| {
385 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 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 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 debug!("Building link graph for backlinks");
436 let link_graph = LinkGraph::build(&self.config, content);
437 trace!("Link graph built");
438
439 if self.config.graph.enabled {
441 debug!("Generating graph visualization");
442 let graph_data = link_graph.to_graph_data();
443
444 let graph_json = serde_json::to_string(&graph_data)?;
446 fs::write(self.output_dir.join("graph.json"), graph_json)?;
447
448 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 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 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 content.sections.par_iter().try_for_each(|(_, section)| {
471 section.posts.par_iter().try_for_each(|post| {
472 let url = post.url(&self.config);
474 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 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 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) .filter(|post| {
507 !rss_config.exclude_encrypted_blocks || !post.has_encrypted_blocks
509 })
510 .collect();
511
512 posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
514
515 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 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 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 content
544 .sections
545 .par_iter()
546 .try_for_each(|(section_name, section)| {
547 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 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 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 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 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 pub fn incremental_build(&mut self, changes: &ChangeSet) -> Result<()> {
617 debug!("Starting incremental build");
618 trace!("Change set: {:?}", changes);
619
620 if changes.full_rebuild {
622 info!("Full rebuild required");
623 return self.build();
624 }
625
626 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 self.process_static_changes(changes)?;
636 return Ok(());
637 }
638
639 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 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 let content = self.process_content(content, &pipeline, &templates)?;
656
657 self.render_html(&content, &templates)?;
659
660 if self.config.text.enabled {
662 self.render_text(&content)?;
663 }
664
665 if changes.rebuild_css {
667 self.rebuild_css_only()?;
668 }
669
670 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 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 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 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 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 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 pub fn config(&self) -> &Config {
747 &self.config
748 }
749}