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::{ComputedPage, 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!("Running before_build hook");
57 self.config.call_before_build()?;
58
59 trace!("Stage 1: Cleaning output directory");
61 self.clean()?;
62
63 trace!("Stage 2: Discovering content");
65 let mut content = self.load_content()?;
66 debug!(
67 "Found {} sections with {} total posts",
68 content.sections.len(),
69 content
70 .sections
71 .values()
72 .map(|s| s.posts.len())
73 .sum::<usize>()
74 );
75
76 self.apply_custom_sorting(&mut content)?;
78
79 trace!("Stage 3: Processing assets");
81 self.process_assets()?;
82
83 trace!("Stage 4: Loading templates");
85 let templates = Templates::new(&self.resolve_path(&self.config.paths.templates))?;
86
87 trace!("Stage 5: Processing content through pipeline");
89 let pipeline = Pipeline::from_config(&self.config);
90 let content = self.process_content(content, &pipeline, &templates)?;
91
92 trace!("Stage 6: Rendering HTML");
94 self.render_html(&content, &templates)?;
95
96 if self.config.text.enabled {
98 trace!("Stage 7: Rendering text output");
99 self.render_text(&content)?;
100 }
101
102 let total_posts: usize = content.sections.values().map(|s| s.posts.len()).sum();
103 info!(
104 "Build complete: {} posts in {} sections",
105 total_posts,
106 content.sections.len()
107 );
108 println!(
109 "Generated {} posts in {} sections",
110 total_posts,
111 content.sections.len()
112 );
113
114 trace!("Running after_build hook");
116 self.config.call_after_build()?;
117
118 Ok(())
119 }
120
121 fn clean(&self) -> Result<()> {
122 if self.output_dir.exists() {
123 debug!("Removing existing output directory: {:?}", self.output_dir);
124 fs::remove_dir_all(&self.output_dir).with_context(|| {
125 format!("Failed to clean output directory: {:?}", self.output_dir)
126 })?;
127 }
128 trace!("Creating output directories");
129 fs::create_dir_all(&self.output_dir)?;
130 fs::create_dir_all(self.output_dir.join("static"))?;
131 Ok(())
132 }
133
134 fn load_content(&self) -> Result<Content> {
135 discover_content(
136 &self.config.paths,
137 &self.config.sections,
138 Some(&self.project_dir),
139 )
140 }
141
142 fn apply_custom_sorting(&self, content: &mut Content) -> Result<()> {
143 for (section_name, section) in content.sections.iter_mut() {
144 if self.config.has_sort_fn(section_name) {
146 debug!("Applying custom sort to section '{}'", section_name);
147
148 section.posts.sort_by(|a, b| {
150 let a_json = serde_json::to_value(a).unwrap_or_default();
152 let b_json = serde_json::to_value(b).unwrap_or_default();
153
154 self.config
156 .call_sort_fn(section_name, &a_json, &b_json)
157 .unwrap_or(std::cmp::Ordering::Equal)
158 });
159 }
160 }
161 Ok(())
162 }
163
164 fn process_assets(&self) -> Result<()> {
165 let static_dir = self.output_dir.join("static");
166 let paths = &self.config.paths;
167
168 debug!("Building CSS from {:?}", self.resolve_path(&paths.styles));
170 build_css(
171 &self.resolve_path(&paths.styles),
172 &static_dir.join(&self.config.build.css_output),
173 self.config.build.minify_css,
174 )?;
175
176 debug!(
178 "Optimizing images (quality: {}, scale: {})",
179 self.config.images.quality, self.config.images.scale_factor
180 );
181 let image_config = ImageConfig {
182 quality: self.config.images.quality,
183 scale_factor: self.config.images.scale_factor,
184 };
185 optimize_images(
186 &self.resolve_path(&paths.static_files),
187 &static_dir,
188 &image_config,
189 )?;
190
191 debug!(
193 "Copying static files from {:?}",
194 self.resolve_path(&paths.static_files)
195 );
196 copy_static_files(&self.resolve_path(&paths.static_files), &static_dir)?;
197
198 Ok(())
199 }
200
201 fn process_content(
202 &self,
203 mut content: Content,
204 pipeline: &Pipeline,
205 templates: &Templates,
206 ) -> Result<Content> {
207 let paths = &self.config.paths;
208
209 if let Some(page) = content.home.take() {
211 let home_path = self.resolve_path(&paths.content).join(&paths.home);
212 let ctx = TransformContext {
213 config: &self.config,
214 current_path: &home_path,
215 base_url: &self.config.site.base_url,
216 };
217 let html = pipeline.process(&page.content, &ctx);
218 content.home = Some(page.with_html(html));
219 }
220
221 let content_dir = self.resolve_path(&paths.content);
223 content.root_pages = content
224 .root_pages
225 .into_iter()
226 .map(|page| {
227 let file_name = page
228 .file_slug
229 .as_ref()
230 .map(|s| format!("{}.md", s))
231 .unwrap_or_else(|| "page.md".to_string());
232 let page_path = content_dir.join(&file_name);
233 let ctx = TransformContext {
234 config: &self.config,
235 current_path: &page_path,
236 base_url: &self.config.site.base_url,
237 };
238 let html = pipeline.process(&page.content, &ctx);
239 page.with_html(html)
240 })
241 .collect();
242
243 content
245 .sections
246 .par_iter_mut()
247 .try_for_each(|(_, section)| {
248 let section_name = §ion.name;
249 section.posts.par_iter_mut().try_for_each(|post| {
250 self.process_single_post(post, section_name, pipeline, paths, templates)
251 })
252 })?;
253
254 Ok(content)
255 }
256
257 fn process_single_post(
259 &self,
260 post: &mut crate::content::Post,
261 section_name: &str,
262 pipeline: &Pipeline,
263 paths: &crate::config::PathsConfig,
264 templates: &Templates,
265 ) -> Result<()> {
266 trace!(
267 "Processing post: {} ({})",
268 post.frontmatter.title, section_name
269 );
270
271 if post.content_type == ContentType::Html {
273 trace!("Post is HTML content, processing through Tera");
274 return self.process_html_post(post, templates);
275 }
276
277 let path = self
279 .resolve_path(&paths.content)
280 .join(section_name)
281 .join(format!("{}.md", post.file_slug));
282 let ctx = TransformContext {
283 config: &self.config,
284 current_path: &path,
285 base_url: &self.config.site.base_url,
286 };
287
288 if post.frontmatter.encrypted {
290 debug!("Encrypting post: {}", post.frontmatter.title);
291 let html = pipeline.process(&post.content, &ctx);
292 let password = resolve_password(
293 &self.config.encryption,
294 post.frontmatter.password.as_deref(),
295 )
296 .with_context(|| {
297 format!(
298 "Failed to resolve password for encrypted post: {}",
299 post.frontmatter.title
300 )
301 })?;
302
303 let encrypted = encrypt_content(&html, &password)
304 .with_context(|| format!("Failed to encrypt post: {}", post.frontmatter.title))?;
305
306 post.encrypted_content = Some(encrypted);
307 post.html = String::new();
308 } else {
309 let preprocess_result = extract_encrypted_blocks(&post.content);
311
312 if preprocess_result.blocks.is_empty() {
313 post.html = pipeline.process(&post.content, &ctx);
315 } else {
316 debug!(
317 "Found {} encrypted blocks in post: {}",
318 preprocess_result.blocks.len(),
319 post.frontmatter.title
320 );
321 let main_html = pipeline.process(&preprocess_result.markdown, &ctx);
323
324 let encrypted_blocks: Result<Vec<_>> = preprocess_result
326 .blocks
327 .par_iter()
328 .map(|block| {
329 let block_password = if let Some(ref pw) = block.password {
331 pw.clone()
332 } else {
333 resolve_password(
334 &self.config.encryption,
335 post.frontmatter.password.as_deref(),
336 )
337 .with_context(|| {
338 format!(
339 "Failed to resolve password for block {} in post: {}",
340 block.id, post.frontmatter.title
341 )
342 })?
343 };
344
345 let block_html = pipeline.process(&block.content, &ctx);
347
348 let encrypted = encrypt_content(&block_html, &block_password)
350 .with_context(|| {
351 format!(
352 "Failed to encrypt block {} in post: {}",
353 block.id, post.frontmatter.title
354 )
355 })?;
356
357 Ok((
358 block.id,
359 encrypted.ciphertext,
360 encrypted.salt,
361 encrypted.nonce,
362 block.password.is_some(),
363 ))
364 })
365 .collect();
366
367 post.html = replace_placeholders(&main_html, &encrypted_blocks?, post.slug());
369 post.has_encrypted_blocks = true;
370 }
371 }
372
373 Ok(())
374 }
375
376 fn process_html_post(&self, post: &mut Post, templates: &Templates) -> Result<()> {
378 let rendered_html = templates.render_html_content(&self.config, post)?;
380
381 if post.frontmatter.encrypted {
383 let password = resolve_password(
384 &self.config.encryption,
385 post.frontmatter.password.as_deref(),
386 )
387 .with_context(|| {
388 format!(
389 "Failed to resolve password for encrypted HTML post: {}",
390 post.frontmatter.title
391 )
392 })?;
393
394 let encrypted = encrypt_content(&rendered_html, &password).with_context(|| {
395 format!("Failed to encrypt HTML post: {}", post.frontmatter.title)
396 })?;
397
398 post.encrypted_content = Some(encrypted);
399 post.html = String::new();
400 } else {
401 let preprocess_result = extract_html_encrypted_blocks(&rendered_html);
403
404 if preprocess_result.blocks.is_empty() {
405 post.html = rendered_html;
407 } else {
408 debug!(
409 "Found {} encrypted blocks in HTML post: {}",
410 preprocess_result.blocks.len(),
411 post.frontmatter.title
412 );
413 let encrypted_blocks: Result<Vec<_>> = preprocess_result
415 .blocks
416 .iter()
417 .map(|block| {
418 let block_password = if let Some(ref pw) = block.password {
420 pw.clone()
421 } else {
422 resolve_password(
423 &self.config.encryption,
424 post.frontmatter.password.as_deref(),
425 )
426 .with_context(|| {
427 format!(
428 "Failed to resolve password for block {} in HTML post: {}",
429 block.id, post.frontmatter.title
430 )
431 })?
432 };
433
434 let encrypted = encrypt_content(&block.content, &block_password)
436 .with_context(|| {
437 format!(
438 "Failed to encrypt block {} in HTML post: {}",
439 block.id, post.frontmatter.title
440 )
441 })?;
442
443 Ok((
444 block.id,
445 encrypted.ciphertext,
446 encrypted.salt,
447 encrypted.nonce,
448 block.password.is_some(),
449 ))
450 })
451 .collect();
452
453 post.html = replace_placeholders(
455 &preprocess_result.markdown,
456 &encrypted_blocks?,
457 post.slug(),
458 );
459 post.has_encrypted_blocks = true;
460 }
461 }
462
463 Ok(())
464 }
465
466 fn render_html(&self, content: &Content, templates: &Templates) -> Result<()> {
467 debug!("Building link graph for backlinks");
469 let link_graph = LinkGraph::build(&self.config, content);
470 trace!("Link graph built");
471
472 let computed = self.compute_data(content);
474 let computed_ref = if computed.as_object().map(|o| o.is_empty()).unwrap_or(true) {
475 None
476 } else {
477 debug!("Computed data available for templates");
478 Some(&computed)
479 };
480
481 if self.config.graph.enabled {
483 debug!("Generating graph visualization");
484 let graph_data = link_graph.to_graph_data();
485
486 let graph_json = serde_json::to_string(&graph_data)?;
488 fs::write(self.output_dir.join("graph.json"), graph_json)?;
489
490 let graph_dir = self.output_dir.join(&self.config.graph.path);
492 fs::create_dir_all(&graph_dir)?;
493 let graph_html = templates.render_graph(&self.config, &graph_data)?;
494 fs::write(graph_dir.join("index.html"), graph_html)?;
495 }
496
497 if let Some(home_page) = &content.home {
499 let html = templates.render_home(&self.config, home_page, content, computed_ref)?;
500 fs::write(self.output_dir.join("index.html"), html)?;
501 }
502
503 for page in &content.root_pages {
505 if let Some(slug) = &page.file_slug {
506 let html = templates.render_root_page(&self.config, page, content, computed_ref)?;
507 fs::write(self.output_dir.join(format!("{}.html", slug)), html)?;
508 }
509 }
510
511 let computed_pages = self.generate_computed_pages(content);
513 if !computed_pages.is_empty() {
514 debug!("Generating {} computed pages", computed_pages.len());
515 for page in &computed_pages {
516 let relative_path = page.path.trim_matches('/');
517 let page_dir = self.output_dir.join(relative_path);
518 fs::create_dir_all(&page_dir)?;
519 let html = templates.render_computed_page(&self.config, page, computed_ref)?;
520 fs::write(page_dir.join("index.html"), html)?;
521 }
522 }
523
524 content.sections.par_iter().try_for_each(|(_, section)| {
526 section.posts.par_iter().try_for_each(|post| {
527 let url = post.url(&self.config);
529 let relative_path = url.trim_matches('/');
531 let post_dir = self.output_dir.join(relative_path);
532 fs::create_dir_all(&post_dir)?;
533 let html = templates.render_post(&self.config, post, &link_graph)?;
534 fs::write(post_dir.join("index.html"), html)?;
535 Ok::<_, anyhow::Error>(())
536 })
537 })?;
538
539 if self.config.rss.enabled {
541 debug!("Generating RSS feed");
542 self.generate_rss(content)?;
543 }
544
545 Ok(())
546 }
547
548 fn generate_rss(&self, content: &Content) -> Result<()> {
549 trace!("Building RSS feed");
550 let rss_config = &self.config.rss;
551
552 let mut posts: Vec<&Post> = content
554 .sections
555 .iter()
556 .filter(|(name, _)| {
557 rss_config.sections.is_empty() || rss_config.sections.contains(name)
558 })
559 .flat_map(|(_, section)| section.posts.iter())
560 .filter(|post| !post.frontmatter.encrypted) .filter(|post| {
562 !rss_config.exclude_encrypted_blocks || !post.has_encrypted_blocks
564 })
565 .collect();
566
567 posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
569
570 posts.truncate(rss_config.limit);
572
573 let rss_xml = generate_rss(&self.config, &posts);
574 fs::write(self.output_dir.join(&rss_config.filename), rss_xml)?;
575
576 Ok(())
577 }
578
579 fn render_text(&self, content: &Content) -> Result<()> {
581 let text_config = &self.config.text;
582 let base_url = &self.config.site.base_url;
583
584 if text_config.include_home
586 && let Some(home_page) = &content.home
587 {
588 let text = format_home_text(
589 &self.config.site.title,
590 &self.config.site.description,
591 &home_page.html,
592 base_url,
593 );
594 fs::write(self.output_dir.join("index.txt"), text)?;
595 }
596
597 content
599 .sections
600 .par_iter()
601 .try_for_each(|(section_name, section)| {
602 if !text_config.sections.is_empty() && !text_config.sections.contains(section_name)
604 {
605 return Ok::<_, anyhow::Error>(());
606 }
607
608 section.posts.par_iter().try_for_each(|post| {
609 if text_config.exclude_encrypted
611 && (post.frontmatter.encrypted || post.has_encrypted_blocks)
612 {
613 return Ok::<_, anyhow::Error>(());
614 }
615
616 let url = post.url(&self.config);
617 let relative_path = url.trim_matches('/');
618 let post_dir = self.output_dir.join(relative_path);
619
620 let date_str = post
622 .frontmatter
623 .date
624 .map(|d| d.format("%Y-%m-%d").to_string());
625
626 let tags = post.frontmatter.tags.as_deref().unwrap_or(&[]);
627
628 let content = if post.frontmatter.encrypted {
630 "[This post is encrypted - visit web version to decrypt]"
631 } else {
632 &post.html
633 };
634
635 let text = format_post_text(
636 &post.frontmatter.title,
637 date_str.as_deref(),
638 post.frontmatter.description.as_deref(),
639 tags,
640 post.reading_time,
641 content,
642 &url,
643 base_url,
644 );
645
646 fs::write(post_dir.join("index.txt"), text)?;
647 Ok::<_, anyhow::Error>(())
648 })
649 })?;
650
651 let text_count: usize = content
653 .sections
654 .iter()
655 .filter(|(name, _)| {
656 text_config.sections.is_empty() || text_config.sections.contains(name)
657 })
658 .flat_map(|(_, section)| section.posts.iter())
659 .filter(|post| {
660 !text_config.exclude_encrypted
661 || (!post.frontmatter.encrypted && !post.has_encrypted_blocks)
662 })
663 .count();
664
665 println!("Generated {} text files", text_count);
666
667 Ok(())
668 }
669
670 pub fn incremental_build(&mut self, changes: &ChangeSet) -> Result<()> {
672 debug!("Starting incremental build");
673 trace!("Change set: {:?}", changes);
674
675 if changes.full_rebuild {
677 info!("Full rebuild required");
678 return self.build();
679 }
680
681 if changes.rebuild_css
683 && !changes.reload_templates
684 && !changes.rebuild_home
685 && changes.content_files.is_empty()
686 {
687 self.rebuild_css_only()?;
688
689 self.process_static_changes(changes)?;
691 return Ok(());
692 }
693
694 if !changes.reload_templates
696 && !changes.rebuild_home
697 && changes.content_files.is_empty()
698 && !changes.rebuild_css
699 {
700 self.process_static_changes(changes)?;
701 return Ok(());
702 }
703
704 let content = self.load_content()?;
706 let templates = Templates::new(&self.resolve_path(&self.config.paths.templates))?;
707 let pipeline = Pipeline::from_config(&self.config);
708
709 let content = self.process_content(content, &pipeline, &templates)?;
711
712 self.render_html(&content, &templates)?;
714
715 if self.config.text.enabled {
717 self.render_text(&content)?;
718 }
719
720 if changes.rebuild_css {
722 self.rebuild_css_only()?;
723 }
724
725 self.process_static_changes(changes)?;
727
728 let total_posts: usize = content.sections.values().map(|s| s.posts.len()).sum();
729 println!(
730 "Rebuilt {} posts in {} sections",
731 total_posts,
732 content.sections.len()
733 );
734
735 Ok(())
736 }
737
738 fn rebuild_css_only(&self) -> Result<()> {
740 let static_dir = self.output_dir.join("static");
741 build_css(
742 &self.resolve_path(&self.config.paths.styles),
743 &static_dir.join(&self.config.build.css_output),
744 self.config.build.minify_css,
745 )?;
746 println!("Rebuilt CSS");
747 Ok(())
748 }
749
750 fn process_static_changes(&self, changes: &ChangeSet) -> Result<()> {
752 let static_dir = self.output_dir.join("static");
753 let source_static = self.resolve_path(&self.config.paths.static_files);
754
755 let image_config = ImageConfig {
756 quality: self.config.images.quality,
757 scale_factor: self.config.images.scale_factor,
758 };
759
760 for rel_path in &changes.image_files {
762 let src = source_static.join(rel_path.as_path());
763 let dest = static_dir.join(rel_path.as_path());
764
765 if src.exists() {
766 if let Some(parent) = dest.parent() {
767 fs::create_dir_all(parent)?;
768 }
769 optimize_single_image(&src, &dest, &image_config)?;
770 println!("Optimized image: {}", rel_path.display());
771 }
772 }
773
774 for rel_path in &changes.static_files {
776 let src = source_static.join(rel_path.as_path());
777 let dest = static_dir.join(rel_path.as_path());
778
779 if src.exists() {
780 if let Some(parent) = dest.parent() {
781 fs::create_dir_all(parent)?;
782 }
783 copy_single_static_file(&src, &dest)?;
784 println!("Copied static file: {}", rel_path.display());
785 }
786 }
787
788 Ok(())
789 }
790
791 pub fn reload_config(&mut self) -> Result<()> {
793 debug!("Reloading config from {:?}", self.project_dir);
794 self.config = crate::config::Config::load(&self.project_dir)?;
795 info!("Config reloaded successfully");
796 Ok(())
797 }
798
799 fn generate_computed_pages(&self, content: &Content) -> Vec<ComputedPage> {
801 if !self.config.has_computed_pages() {
802 return Vec::new();
803 }
804
805 let sections_json = match serde_json::to_string(&content.sections) {
807 Ok(json) => json,
808 Err(e) => {
809 log::warn!("Failed to serialize sections for computed_pages: {}", e);
810 return Vec::new();
811 }
812 };
813
814 match self.config.call_computed_pages(§ions_json) {
815 Ok(pages) => pages,
816 Err(e) => {
817 log::warn!("Failed to generate computed pages: {}", e);
818 Vec::new()
819 }
820 }
821 }
822
823 fn compute_data(&self, content: &Content) -> serde_json::Value {
825 let computed_names = self.config.computed_names();
826 if computed_names.is_empty() {
827 return serde_json::Value::Object(serde_json::Map::new());
828 }
829
830 let sections_json = match serde_json::to_string(&content.sections) {
832 Ok(json) => json,
833 Err(e) => {
834 log::warn!("Failed to serialize sections for computed: {}", e);
835 return serde_json::Value::Object(serde_json::Map::new());
836 }
837 };
838
839 let mut computed = serde_json::Map::new();
840 for name in computed_names {
841 match self.config.call_computed(name, §ions_json) {
842 Ok(value) => {
843 computed.insert(name.to_string(), value);
844 }
845 Err(e) => {
846 log::warn!("Failed to compute '{}': {}", name, e);
847 }
848 }
849 }
850
851 serde_json::Value::Object(computed)
852 }
853
854 pub fn config(&self) -> &Config {
856 &self.config
857 }
858}