1use crate::models::{ContentStatus, ContentType};
2use crate::services::{content, settings, tags};
3use crate::web::AppState;
4use crate::Config;
5use anyhow::Result;
6use std::fs;
7use std::path::Path;
8use std::sync::Arc;
9use tera::Context;
10
11const MAX_BUILD_CONTENT: usize = 10000;
14
15pub async fn run(config_path: &Path, output_dir: &Path, base_url: Option<String>) -> Result<()> {
16 let config = Config::load(config_path)?;
17 let db = crate::Database::open(&config.database.path)?;
18
19 let site_url = base_url.unwrap_or_else(|| config.site.url.clone());
20
21 let state = Arc::new(AppState::new(
22 config.clone(),
23 config_path.to_path_buf(),
24 db.clone(),
25 true,
26 )?);
27
28 fs::create_dir_all(output_dir)?;
29
30 tracing::info!("Building static site to {}", output_dir.display());
31
32 build_index(&state, output_dir, &site_url)?;
33 build_posts(&state, output_dir)?;
34 build_pages(&state, output_dir)?;
35 build_tags(&state, output_dir)?;
36 build_search(&state, output_dir)?;
37 build_feeds(&state, output_dir, &site_url)?;
38 copy_media(&config, output_dir)?;
39
40 tracing::info!("Static site build complete");
41 Ok(())
42}
43
44fn make_context(state: &AppState) -> Context {
45 let config = state.config();
46 let mut ctx = Context::new();
47 ctx.insert("site", &config.site);
48 ctx.insert("theme", &config.theme);
49 ctx.insert("homepage_config", &config.homepage);
50 ctx.insert("production_mode", &true);
51 ctx.insert("user", &None::<()>);
52 if config.theme.custom.has_customizations() {
53 ctx.insert("theme_custom_css", &config.theme.custom.to_css_variables());
54 }
55 ctx
56}
57
58fn build_index(state: &AppState, output_dir: &Path, _site_url: &str) -> Result<()> {
59 let posts_per_page = state.config().content.posts_per_page;
60 let total = content::count_content(
61 &state.db,
62 Some(ContentType::Post),
63 Some(ContentStatus::Published),
64 )?;
65 let total_pages = ((total as usize) + posts_per_page - 1) / posts_per_page;
66 let homepage_settings = settings::get_homepage_settings(&state.db).unwrap_or_default();
67 let pages = content::list_published_content(&state.db, ContentType::Page, 100, 0)?;
68
69 for page_num in 1..=total_pages.max(1) {
70 let offset = (page_num - 1) * posts_per_page;
71 let posts =
72 content::list_published_content(&state.db, ContentType::Post, posts_per_page, offset)?;
73
74 let mut ctx = make_context(state);
75 ctx.insert("posts", &posts);
76 ctx.insert("pages", &pages);
77 ctx.insert("homepage", &homepage_settings);
78 ctx.insert("page", &page_num);
79 ctx.insert("total_pages", &total_pages);
80 ctx.insert("has_prev", &(page_num > 1));
81 ctx.insert("has_next", &(page_num < total_pages));
82 ctx.insert("prev_page", &(page_num - 1));
83 ctx.insert("next_page", &(page_num + 1));
84
85 let html = state.templates.render("public/index.html", &ctx)?;
86
87 if page_num == 1 {
88 fs::write(output_dir.join("index.html"), &html)?;
89 fs::create_dir_all(output_dir.join("posts"))?;
90 fs::write(output_dir.join("posts").join("index.html"), &html)?;
91 }
92
93 if total_pages > 1 {
94 let page_dir = output_dir
95 .join("posts")
96 .join("page")
97 .join(page_num.to_string());
98 fs::create_dir_all(&page_dir)?;
99 fs::write(page_dir.join("index.html"), &html)?;
100 }
101 }
102
103 tracing::info!("Built index with {} page(s)", total_pages.max(1));
104 Ok(())
105}
106
107fn build_posts(state: &AppState, output_dir: &Path) -> Result<()> {
108 let posts =
109 content::list_published_content(&state.db, ContentType::Post, MAX_BUILD_CONTENT, 0)?;
110 let posts_dir = output_dir.join("posts");
111 fs::create_dir_all(&posts_dir)?;
112
113 for post in &posts {
114 let mut ctx = make_context(state);
115 ctx.insert("content", &post);
116
117 let html = state.templates.render("public/post.html", &ctx)?;
118
119 let post_dir = posts_dir.join(&post.content.slug);
120 fs::create_dir_all(&post_dir)?;
121 fs::write(post_dir.join("index.html"), html)?;
122 }
123
124 tracing::info!("Built {} posts", posts.len());
125 Ok(())
126}
127
128fn build_pages(state: &AppState, output_dir: &Path) -> Result<()> {
129 let pages =
130 content::list_published_content(&state.db, ContentType::Page, MAX_BUILD_CONTENT, 0)?;
131
132 for page in &pages {
133 let mut ctx = make_context(state);
134 ctx.insert("content", &page);
135
136 let html = state.templates.render("public/page.html", &ctx)?;
137
138 let page_dir = output_dir.join(&page.content.slug);
139 fs::create_dir_all(&page_dir)?;
140 fs::write(page_dir.join("index.html"), html)?;
141 }
142
143 tracing::info!("Built {} pages", pages.len());
144 Ok(())
145}
146
147fn build_tags(state: &AppState, output_dir: &Path) -> Result<()> {
148 let tags_dir = output_dir.join("tags");
149 fs::create_dir_all(&tags_dir)?;
150
151 let all_tags = tags::list_tags_with_counts(&state.db)?;
152
153 let mut ctx = make_context(state);
154 ctx.insert("tags", &all_tags);
155 let html = state.templates.render("public/tags.html", &ctx)?;
156 fs::write(tags_dir.join("index.html"), html)?;
157
158 for tag in &all_tags {
159 let posts = tags::get_posts_by_tag(&state.db, &tag.tag.slug)?;
160 let mut ctx = make_context(state);
161 ctx.insert("tag", tag);
162 ctx.insert("posts", &posts);
163
164 let html = state.templates.render("public/tag.html", &ctx)?;
165
166 let tag_dir = tags_dir.join(&tag.tag.slug);
167 fs::create_dir_all(&tag_dir)?;
168 fs::write(tag_dir.join("index.html"), html)?;
169 }
170
171 tracing::info!("Built {} tag pages", all_tags.len());
172 Ok(())
173}
174
175fn build_search(state: &AppState, output_dir: &Path) -> Result<()> {
176 let search_dir = output_dir.join("search");
177 fs::create_dir_all(&search_dir)?;
178
179 let posts =
180 content::list_published_content(&state.db, ContentType::Post, MAX_BUILD_CONTENT, 0)?;
181
182 let search_index: Vec<serde_json::Value> = posts
183 .iter()
184 .map(|post| {
185 serde_json::json!({
186 "slug": post.content.slug,
187 "title": post.content.title,
188 "excerpt": post.content.excerpt,
189 "body": post.content.body_markdown,
190 })
191 })
192 .collect();
193
194 fs::write(
195 search_dir.join("index.json"),
196 serde_json::to_string(&search_index)?,
197 )?;
198
199 let search_html = generate_static_search_page(state)?;
200 fs::write(search_dir.join("index.html"), search_html)?;
201
202 tracing::info!("Built search page with {} indexed posts", posts.len());
203 Ok(())
204}
205
206fn generate_static_search_page(state: &AppState) -> Result<String> {
207 let mut ctx = make_context(state);
208 ctx.insert("query", "");
209 ctx.insert("results", &Vec::<()>::new());
210
211 let template_html = state.templates.render("public/search.html", &ctx)?;
212
213 let search_script = r#"
214<script>
215(function() {
216 let searchIndex = null;
217 const form = document.querySelector('.search-form');
218 const input = form.querySelector('input[name="q"]');
219 const resultsSection = document.querySelector('.post-list') || document.createElement('section');
220
221 if (!document.querySelector('.post-list')) {
222 resultsSection.className = 'post-list';
223 form.after(resultsSection);
224 }
225
226 fetch('/search/index.json')
227 .then(r => r.json())
228 .then(data => { searchIndex = data; })
229 .catch(console.error);
230
231 form.addEventListener('submit', function(e) {
232 e.preventDefault();
233 performSearch(input.value.trim().toLowerCase());
234 });
235
236 input.addEventListener('input', function() {
237 if (this.value.length > 2) {
238 performSearch(this.value.trim().toLowerCase());
239 } else if (this.value.length === 0) {
240 resultsSection.innerHTML = '';
241 }
242 });
243
244 function performSearch(query) {
245 if (!searchIndex || !query) {
246 resultsSection.innerHTML = '';
247 return;
248 }
249
250 const results = searchIndex.filter(post =>
251 post.title.toLowerCase().includes(query) ||
252 (post.excerpt && post.excerpt.toLowerCase().includes(query)) ||
253 post.body.toLowerCase().includes(query)
254 );
255
256 if (results.length === 0) {
257 resultsSection.innerHTML = '<p style="color: var(--text-muted);">No results found for "' + query + '"</p><div class="empty-state"><p>Try a different search term.</p></div>';
258 return;
259 }
260
261 let html = '<p style="color: var(--text-muted); margin-bottom: 1.5rem;">' + results.length + ' result' + (results.length !== 1 ? 's' : '') + ' for "' + query + '"</p>';
262 results.forEach(post => {
263 html += '<article class="post-card"><h2><a href="/posts/' + post.slug + '">' + post.title + '</a></h2>';
264 if (post.excerpt) {
265 html += '<p class="post-excerpt">' + post.excerpt + '</p>';
266 }
267 html += '</article>';
268 });
269 resultsSection.innerHTML = html;
270 }
271})();
272</script>
273"#;
274
275 let html = template_html.replace("</body>", &format!("{}</body>", search_script));
276
277 Ok(html)
278}
279
280fn build_feeds(state: &AppState, output_dir: &Path, site_url: &str) -> Result<()> {
281 let posts = content::list_published_content(&state.db, ContentType::Post, 20, 0)?;
282 let config = state.config();
283
284 let rss = generate_rss(&config.site, site_url, &posts);
285 fs::write(output_dir.join("feed.xml"), rss)?;
286
287 let json_feed = generate_json_feed(&config.site, site_url, &posts);
288 fs::write(output_dir.join("feed.json"), json_feed)?;
289
290 let sitemap = generate_sitemap(state, site_url)?;
291 fs::write(output_dir.join("sitemap.xml"), sitemap)?;
292
293 tracing::info!("Built RSS, JSON Feed, and sitemap");
294 Ok(())
295}
296
297fn generate_rss(
298 site: &crate::config::SiteConfig,
299 site_url: &str,
300 posts: &[crate::models::ContentWithTags],
301) -> String {
302 let mut items = String::new();
303 for post in posts {
304 let pub_date = post
305 .content
306 .published_at
307 .as_ref()
308 .unwrap_or(&post.content.created_at);
309 let excerpt = post.content.excerpt.as_deref().unwrap_or("");
310 items.push_str(&format!(
311 r#"<item>
312<title>{}</title>
313<link>{}/posts/{}</link>
314<guid>{}/posts/{}</guid>
315<pubDate>{}</pubDate>
316<description><![CDATA[{}]]></description>
317</item>
318"#,
319 xml_escape(&post.content.title),
320 site_url,
321 post.content.slug,
322 site_url,
323 post.content.slug,
324 pub_date,
325 excerpt
326 ));
327 }
328
329 format!(
330 r#"<?xml version="1.0" encoding="UTF-8"?>
331<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
332<channel>
333<title>{}</title>
334<link>{}</link>
335<description>{}</description>
336<language>{}</language>
337<atom:link href="{}/feed.xml" rel="self" type="application/rss+xml"/>
338{}
339</channel>
340</rss>"#,
341 xml_escape(&site.title),
342 site_url,
343 xml_escape(&site.description),
344 site.language,
345 site_url,
346 items
347 )
348}
349
350fn generate_json_feed(
351 site: &crate::config::SiteConfig,
352 site_url: &str,
353 posts: &[crate::models::ContentWithTags],
354) -> String {
355 let items: Vec<serde_json::Value> = posts
356 .iter()
357 .map(|post| {
358 serde_json::json!({
359 "id": format!("{}/posts/{}", site_url, post.content.slug),
360 "url": format!("{}/posts/{}", site_url, post.content.slug),
361 "title": post.content.title,
362 "content_html": post.content.body_html,
363 "summary": post.content.excerpt,
364 "date_published": post.content.published_at.as_ref().unwrap_or(&post.content.created_at),
365 "tags": post.tags.iter().map(|t| &t.name).collect::<Vec<_>>()
366 })
367 })
368 .collect();
369
370 serde_json::json!({
371 "version": "https://jsonfeed.org/version/1.1",
372 "title": site.title,
373 "home_page_url": site_url,
374 "feed_url": format!("{}/feed.json", site_url),
375 "description": site.description,
376 "language": site.language,
377 "items": items
378 })
379 .to_string()
380}
381
382fn generate_sitemap(state: &AppState, site_url: &str) -> Result<String> {
383 let mut urls = String::new();
384
385 urls.push_str(&format!(
386 "<url><loc>{}</loc><changefreq>daily</changefreq><priority>1.0</priority></url>\n",
387 site_url
388 ));
389
390 let posts =
391 content::list_published_content(&state.db, ContentType::Post, MAX_BUILD_CONTENT, 0)?;
392 for post in posts {
393 urls.push_str(&format!(
394 "<url><loc>{}/posts/{}</loc><lastmod>{}</lastmod><changefreq>weekly</changefreq></url>\n",
395 site_url,
396 post.content.slug,
397 post.content.updated_at.split('T').next().unwrap_or(&post.content.updated_at)
398 ));
399 }
400
401 let pages =
402 content::list_published_content(&state.db, ContentType::Page, MAX_BUILD_CONTENT, 0)?;
403 for page in pages {
404 urls.push_str(&format!(
405 "<url><loc>{}/{}</loc><lastmod>{}</lastmod><changefreq>monthly</changefreq></url>\n",
406 site_url,
407 page.content.slug,
408 page.content
409 .updated_at
410 .split('T')
411 .next()
412 .unwrap_or(&page.content.updated_at)
413 ));
414 }
415
416 let all_tags = tags::list_tags_with_counts(&state.db)?;
417 urls.push_str(&format!(
418 "<url><loc>{}/tags</loc><changefreq>weekly</changefreq></url>\n",
419 site_url
420 ));
421 for tag in all_tags {
422 urls.push_str(&format!(
423 "<url><loc>{}/tags/{}</loc><changefreq>weekly</changefreq></url>\n",
424 site_url, tag.tag.slug
425 ));
426 }
427
428 Ok(format!(
429 r#"<?xml version="1.0" encoding="UTF-8"?>
430<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
431{}
432</urlset>"#,
433 urls
434 ))
435}
436
437fn copy_media(config: &Config, output_dir: &Path) -> Result<()> {
438 let media_src = Path::new(&config.media.upload_dir);
439 if !media_src.exists() {
440 return Ok(());
441 }
442
443 let media_dest = output_dir.join("media");
444 fs::create_dir_all(&media_dest)?;
445
446 let mut count = 0;
447 for entry in fs::read_dir(media_src)? {
448 let entry = entry?;
449 let path = entry.path();
450 if path.is_file() {
451 if let Some(filename) = path.file_name() {
452 fs::copy(&path, media_dest.join(filename))?;
453 count += 1;
454 }
455 }
456 }
457
458 tracing::info!("Copied {} media files", count);
459 Ok(())
460}
461
462fn xml_escape(s: &str) -> String {
463 s.replace('&', "&")
464 .replace('<', "<")
465 .replace('>', ">")
466 .replace('"', """)
467 .replace('\'', "'")
468}