Skip to main content

pebble_cms/cli/
build.rs

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
11/// Maximum number of content items to fetch for static site generation.
12/// This is intentionally high to ensure all content is included in the build.
13const 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('&', "&amp;")
464        .replace('<', "&lt;")
465        .replace('>', "&gt;")
466        .replace('"', "&quot;")
467        .replace('\'', "&apos;")
468}