rs_web/
build.rs

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
24/// Main build orchestrator
25pub 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    /// Resolve a path relative to the project directory
41    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        // Run before_build hook
56        trace!("Running before_build hook");
57        self.config.call_before_build()?;
58
59        // Stage 1: Clean output directory
60        trace!("Stage 1: Cleaning output directory");
61        self.clean()?;
62
63        // Stage 2: Discover and load content
64        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        // Stage 2.5: Apply custom sort functions
77        self.apply_custom_sorting(&mut content)?;
78
79        // Stage 3: Process assets
80        trace!("Stage 3: Processing assets");
81        self.process_assets()?;
82
83        // Stage 4: Load templates (needed for HTML content processing)
84        trace!("Stage 4: Loading templates");
85        let templates = Templates::new(&self.resolve_path(&self.config.paths.templates))?;
86
87        // Stage 5: Process content through pipeline (markdown) or Tera (HTML)
88        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        // Stage 6: Render and write HTML
93        trace!("Stage 6: Rendering HTML");
94        self.render_html(&content, &templates)?;
95
96        // Stage 7: Render text output (if enabled)
97        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        // Run after_build hook
115        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            // Check if this section has a custom sort function
145            if self.config.has_sort_fn(section_name) {
146                debug!("Applying custom sort to section '{}'", section_name);
147
148                // Sort using the Lua function
149                section.posts.sort_by(|a, b| {
150                    // Convert posts to JSON for Lua
151                    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                    // Call the Lua sort function
155                    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        // Build CSS
169        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        // Optimize images
177        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        // Copy other static files
192        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        // Process home page
210        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        // Process root pages
222        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        // Process all posts
244        content
245            .sections
246            .par_iter_mut()
247            .try_for_each(|(_, section)| {
248                let section_name = &section.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    /// Process a single post through the markdown pipeline (or Tera for HTML) and encryption
258    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        // Handle HTML content files - process through Tera
272        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        // Markdown processing
278        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        // Check if post should be fully encrypted
289        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            // Check for partial encryption (:::encrypted blocks)
310            let preprocess_result = extract_encrypted_blocks(&post.content);
311
312            if preprocess_result.blocks.is_empty() {
313                // No encrypted blocks, process normally
314                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                // Process main content with placeholders
322                let main_html = pipeline.process(&preprocess_result.markdown, &ctx);
323
324                // Process and encrypt each block
325                let encrypted_blocks: Result<Vec<_>> = preprocess_result
326                    .blocks
327                    .par_iter()
328                    .map(|block| {
329                        // Use block-specific password if provided, otherwise fall back to global
330                        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                        // Render block content through pipeline
346                        let block_html = pipeline.process(&block.content, &ctx);
347
348                        // Encrypt the rendered HTML
349                        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                // Replace placeholders with encrypted HTML
368                post.html = replace_placeholders(&main_html, &encrypted_blocks?, post.slug());
369                post.has_encrypted_blocks = true;
370            }
371        }
372
373        Ok(())
374    }
375
376    /// Process an HTML content file through Tera templating with encryption support
377    fn process_html_post(&self, post: &mut Post, templates: &Templates) -> Result<()> {
378        // Render content through Tera first
379        let rendered_html = templates.render_html_content(&self.config, post)?;
380
381        // Check if post should be fully encrypted
382        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            // Check for partial encryption (<encrypted> blocks)
402            let preprocess_result = extract_html_encrypted_blocks(&rendered_html);
403
404            if preprocess_result.blocks.is_empty() {
405                // No encrypted blocks, use rendered HTML as-is
406                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                // Process and encrypt each block
414                let encrypted_blocks: Result<Vec<_>> = preprocess_result
415                    .blocks
416                    .iter()
417                    .map(|block| {
418                        // Use block-specific password if provided, otherwise fall back to global
419                        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                        // Encrypt the block content (already rendered through Tera)
435                        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                // Replace placeholders with encrypted HTML
454                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        // Build link graph for backlinks
468        debug!("Building link graph for backlinks");
469        let link_graph = LinkGraph::build(&self.config, content);
470        trace!("Link graph built");
471
472        // Compute data from Lua config (for templates like tags.html)
473        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        // Generate graph if enabled
482        if self.config.graph.enabled {
483            debug!("Generating graph visualization");
484            let graph_data = link_graph.to_graph_data();
485
486            // Write graph.json for visualization
487            let graph_json = serde_json::to_string(&graph_data)?;
488            fs::write(self.output_dir.join("graph.json"), graph_json)?;
489
490            // Render graph page
491            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        // Render home page
498        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        // Render root pages (404.md -> 404.html, etc.)
504        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        // Render computed pages (e.g., /tags/array/, /tags/binary-search/)
512        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        // Render posts for each section
525        content.sections.par_iter().try_for_each(|(_, section)| {
526            section.posts.par_iter().try_for_each(|post| {
527                // Use resolved URL to determine output path
528                let url = post.url(&self.config);
529                // Convert URL to file path: /blog/2024/01/hello/ -> blog/2024/01/hello
530                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        // Generate RSS feed
540        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        // Collect posts from specified sections (or all if empty)
553        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) // Exclude fully encrypted posts
561            .filter(|post| {
562                // Optionally exclude posts with encrypted blocks
563                !rss_config.exclude_encrypted_blocks || !post.has_encrypted_blocks
564            })
565            .collect();
566
567        // Sort by date (newest first)
568        posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
569
570        // Limit number of items
571        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    /// Generate plain text versions of posts for curl-friendly access
580    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        // Render home page text if enabled
585        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        // Render posts for each section in parallel
598        content
599            .sections
600            .par_iter()
601            .try_for_each(|(section_name, section)| {
602                // Check if this section should be included
603                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                    // Skip encrypted posts if configured
610                    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                    // Format date for display
621                    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                    // For fully encrypted posts, use placeholder content
629                    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        // Count text files generated
652        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    /// Perform an incremental build based on what changed
671    pub fn incremental_build(&mut self, changes: &ChangeSet) -> Result<()> {
672        debug!("Starting incremental build");
673        trace!("Change set: {:?}", changes);
674
675        // If full rebuild is needed, just do a regular build
676        if changes.full_rebuild {
677            info!("Full rebuild required");
678            return self.build();
679        }
680
681        // Handle CSS-only changes (fastest path)
682        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            // Also handle any static/image changes
690            self.process_static_changes(changes)?;
691            return Ok(());
692        }
693
694        // Handle static file changes without content rebuild
695        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        // For template or content changes, we need to rebuild content
705        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        // Process all content (could be optimized further for single-file changes)
710        let content = self.process_content(content, &pipeline, &templates)?;
711
712        // Render HTML
713        self.render_html(&content, &templates)?;
714
715        // Render text if enabled
716        if self.config.text.enabled {
717            self.render_text(&content)?;
718        }
719
720        // Handle any CSS changes
721        if changes.rebuild_css {
722            self.rebuild_css_only()?;
723        }
724
725        // Handle static/image changes
726        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    /// Rebuild only CSS
739    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    /// Process static file and image changes
751    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        // Process changed images
761        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        // Process changed static files
775        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    /// Reload config from disk
792    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    /// Generate computed pages from Lua config
800    fn generate_computed_pages(&self, content: &Content) -> Vec<ComputedPage> {
801        if !self.config.has_computed_pages() {
802            return Vec::new();
803        }
804
805        // Serialize sections to JSON for Lua
806        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(&sections_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    /// Compute computed data from Lua config
824    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        // Serialize sections to JSON for Lua
831        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, &sections_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    /// Get a reference to the current config
855    pub fn config(&self) -> &Config {
856        &self.config
857    }
858}