1use anyhow::{Context, Result};
2use rayon::prelude::*;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::assets::{ImageConfig, build_css, copy_static_files, optimize_images};
7use crate::config::Config;
8use crate::content::{Content, ContentType, Post, discover_content};
9use crate::encryption::{encrypt_content, resolve_password};
10use crate::links::LinkGraph;
11use crate::markdown::{
12 Pipeline, TransformContext, extract_encrypted_blocks, extract_html_encrypted_blocks,
13 replace_placeholders,
14};
15use crate::rss::generate_rss;
16use crate::templates::Templates;
17
18pub struct Builder {
20 config: Config,
21 output_dir: PathBuf,
22}
23
24impl Builder {
25 pub fn new(config: Config, output_dir: PathBuf) -> Self {
26 Self { config, output_dir }
27 }
28
29 pub fn build(&mut self) -> Result<()> {
30 self.clean()?;
32
33 let content = self.load_content()?;
35
36 self.process_assets()?;
38
39 let templates = Templates::new(&self.config.paths.templates)?;
41
42 let pipeline = Pipeline::from_config(&self.config);
44 let content = self.process_content(content, &pipeline, &templates)?;
45
46 self.render_html(&content, &templates)?;
48
49 let total_posts: usize = content.sections.values().map(|s| s.posts.len()).sum();
50 println!(
51 "Generated {} posts in {} sections",
52 total_posts,
53 content.sections.len()
54 );
55
56 Ok(())
57 }
58
59 fn clean(&self) -> Result<()> {
60 if self.output_dir.exists() {
61 fs::remove_dir_all(&self.output_dir).with_context(|| {
62 format!("Failed to clean output directory: {:?}", self.output_dir)
63 })?;
64 }
65 fs::create_dir_all(&self.output_dir)?;
66 fs::create_dir_all(self.output_dir.join("static"))?;
67 Ok(())
68 }
69
70 fn load_content(&self) -> Result<Content> {
71 discover_content(&self.config.paths)
72 }
73
74 fn process_assets(&self) -> Result<()> {
75 let static_dir = self.output_dir.join("static");
76 let paths = &self.config.paths;
77
78 build_css(
80 Path::new(&paths.styles),
81 &static_dir.join("rs.css"),
82 self.config.build.minify_css,
83 )?;
84
85 let image_config = ImageConfig {
87 quality: self.config.images.quality,
88 scale_factor: self.config.images.scale_factor,
89 };
90 optimize_images(Path::new(&paths.static_files), &static_dir, &image_config)?;
91
92 copy_static_files(Path::new(&paths.static_files), &static_dir)?;
94
95 Ok(())
96 }
97
98 fn process_content(
99 &self,
100 mut content: Content,
101 pipeline: &Pipeline,
102 templates: &Templates,
103 ) -> Result<Content> {
104 let paths = &self.config.paths;
105
106 if let Some(page) = content.home.take() {
108 let home_path = format!("{}/{}", paths.content, paths.home);
109 let ctx = TransformContext {
110 config: &self.config,
111 current_path: Path::new(&home_path),
112 base_url: &self.config.site.base_url,
113 };
114 let html = pipeline.process(&page.content, &ctx);
115 content.home = Some(page.with_html(html));
116 }
117
118 content
120 .sections
121 .par_iter_mut()
122 .try_for_each(|(_, section)| {
123 let section_name = §ion.name;
124 section.posts.par_iter_mut().try_for_each(|post| {
125 self.process_single_post(post, section_name, pipeline, paths, templates)
126 })
127 })?;
128
129 Ok(content)
130 }
131
132 fn process_single_post(
134 &self,
135 post: &mut crate::content::Post,
136 section_name: &str,
137 pipeline: &Pipeline,
138 paths: &crate::config::PathsConfig,
139 templates: &Templates,
140 ) -> Result<()> {
141 if post.content_type == ContentType::Html {
143 return self.process_html_post(post, templates);
144 }
145
146 let path_str = format!("{}/{}/{}.md", paths.content, section_name, post.file_slug);
148 let path = PathBuf::from(&path_str);
149 let ctx = TransformContext {
150 config: &self.config,
151 current_path: &path,
152 base_url: &self.config.site.base_url,
153 };
154
155 if post.frontmatter.encrypted {
157 let html = pipeline.process(&post.content, &ctx);
158 let password = resolve_password(
159 &self.config.encryption,
160 post.frontmatter.password.as_deref(),
161 )
162 .with_context(|| {
163 format!(
164 "Failed to resolve password for encrypted post: {}",
165 post.frontmatter.title
166 )
167 })?;
168
169 let encrypted = encrypt_content(&html, &password)
170 .with_context(|| format!("Failed to encrypt post: {}", post.frontmatter.title))?;
171
172 post.encrypted_content = Some(encrypted);
173 post.html = String::new();
174 } else {
175 let preprocess_result = extract_encrypted_blocks(&post.content);
177
178 if preprocess_result.blocks.is_empty() {
179 post.html = pipeline.process(&post.content, &ctx);
181 } else {
182 let main_html = pipeline.process(&preprocess_result.markdown, &ctx);
184
185 let encrypted_blocks: Result<Vec<_>> = preprocess_result
187 .blocks
188 .par_iter()
189 .map(|block| {
190 let block_password = if let Some(ref pw) = block.password {
192 pw.clone()
193 } else {
194 resolve_password(
195 &self.config.encryption,
196 post.frontmatter.password.as_deref(),
197 )
198 .with_context(|| {
199 format!(
200 "Failed to resolve password for block {} in post: {}",
201 block.id, post.frontmatter.title
202 )
203 })?
204 };
205
206 let block_html = pipeline.process(&block.content, &ctx);
208
209 let encrypted = encrypt_content(&block_html, &block_password)
211 .with_context(|| {
212 format!(
213 "Failed to encrypt block {} in post: {}",
214 block.id, post.frontmatter.title
215 )
216 })?;
217
218 Ok((
219 block.id,
220 encrypted.ciphertext,
221 encrypted.salt,
222 encrypted.nonce,
223 block.password.is_some(),
224 ))
225 })
226 .collect();
227
228 post.html = replace_placeholders(&main_html, &encrypted_blocks?, post.slug());
230 post.has_encrypted_blocks = true;
231 }
232 }
233
234 Ok(())
235 }
236
237 fn process_html_post(&self, post: &mut Post, templates: &Templates) -> Result<()> {
239 let rendered_html = templates.render_html_content(&self.config, post)?;
241
242 if post.frontmatter.encrypted {
244 let password = resolve_password(
245 &self.config.encryption,
246 post.frontmatter.password.as_deref(),
247 )
248 .with_context(|| {
249 format!(
250 "Failed to resolve password for encrypted HTML post: {}",
251 post.frontmatter.title
252 )
253 })?;
254
255 let encrypted = encrypt_content(&rendered_html, &password).with_context(|| {
256 format!("Failed to encrypt HTML post: {}", post.frontmatter.title)
257 })?;
258
259 post.encrypted_content = Some(encrypted);
260 post.html = String::new();
261 } else {
262 let preprocess_result = extract_html_encrypted_blocks(&rendered_html);
264
265 if preprocess_result.blocks.is_empty() {
266 post.html = rendered_html;
268 } else {
269 let encrypted_blocks: Result<Vec<_>> = preprocess_result
271 .blocks
272 .iter()
273 .map(|block| {
274 let block_password = if let Some(ref pw) = block.password {
276 pw.clone()
277 } else {
278 resolve_password(
279 &self.config.encryption,
280 post.frontmatter.password.as_deref(),
281 )
282 .with_context(|| {
283 format!(
284 "Failed to resolve password for block {} in HTML post: {}",
285 block.id, post.frontmatter.title
286 )
287 })?
288 };
289
290 let encrypted = encrypt_content(&block.content, &block_password)
292 .with_context(|| {
293 format!(
294 "Failed to encrypt block {} in HTML post: {}",
295 block.id, post.frontmatter.title
296 )
297 })?;
298
299 Ok((
300 block.id,
301 encrypted.ciphertext,
302 encrypted.salt,
303 encrypted.nonce,
304 block.password.is_some(),
305 ))
306 })
307 .collect();
308
309 post.html = replace_placeholders(
311 &preprocess_result.markdown,
312 &encrypted_blocks?,
313 post.slug(),
314 );
315 post.has_encrypted_blocks = true;
316 }
317 }
318
319 Ok(())
320 }
321
322 fn render_html(&self, content: &Content, templates: &Templates) -> Result<()> {
323 let link_graph = LinkGraph::build(&self.config, content);
325
326 if self.config.graph.enabled {
328 let graph_data = link_graph.to_graph_data();
329
330 let graph_json = serde_json::to_string(&graph_data)?;
332 fs::write(self.output_dir.join("graph.json"), graph_json)?;
333
334 let graph_dir = self.output_dir.join(&self.config.graph.path);
336 fs::create_dir_all(&graph_dir)?;
337 let graph_html = templates.render_graph(&self.config, &graph_data)?;
338 fs::write(graph_dir.join("index.html"), graph_html)?;
339 }
340
341 if let Some(home_page) = &content.home {
343 let html = templates.render_home(&self.config, home_page, content)?;
344 fs::write(self.output_dir.join("index.html"), html)?;
345 }
346
347 content.sections.par_iter().try_for_each(|(_, section)| {
349 section.posts.par_iter().try_for_each(|post| {
350 let url = post.url(&self.config);
352 let relative_path = url.trim_matches('/');
354 let post_dir = self.output_dir.join(relative_path);
355 fs::create_dir_all(&post_dir)?;
356 let html = templates.render_post(&self.config, post, &link_graph)?;
357 fs::write(post_dir.join("index.html"), html)?;
358 Ok::<_, anyhow::Error>(())
359 })
360 })?;
361
362 if self.config.rss.enabled {
364 self.generate_rss(content)?;
365 }
366
367 Ok(())
368 }
369
370 fn generate_rss(&self, content: &Content) -> Result<()> {
371 let rss_config = &self.config.rss;
372
373 let mut posts: Vec<&Post> = content
375 .sections
376 .iter()
377 .filter(|(name, _)| {
378 rss_config.sections.is_empty() || rss_config.sections.contains(name)
379 })
380 .flat_map(|(_, section)| section.posts.iter())
381 .filter(|post| !post.frontmatter.encrypted) .filter(|post| {
383 !rss_config.exclude_encrypted_blocks || !post.has_encrypted_blocks
385 })
386 .collect();
387
388 posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
390
391 posts.truncate(rss_config.limit);
393
394 let rss_xml = generate_rss(&self.config, &posts);
395 fs::write(self.output_dir.join(&rss_config.filename), rss_xml)?;
396
397 Ok(())
398 }
399}