Skip to main content

pebble_cms/services/
content.rs

1use crate::models::{
2    Content, ContentStatus, ContentSummary, ContentType, ContentWithTags, CreateContent, Tag,
3    UpdateContent, UserSummary,
4};
5use crate::services::markdown::MarkdownRenderer;
6use crate::services::slug::{generate_slug, validate_slug};
7use crate::Database;
8use anyhow::{bail, Result};
9use once_cell::sync::Lazy;
10use regex::Regex;
11
12static SNIPPET_REGEX: Lazy<Regex> = Lazy::new(|| {
13    Regex::new(r#"\[snippet\s+slug="([^"]+)"\]"#).expect("Invalid snippet regex pattern")
14});
15
16const MAX_TITLE_LENGTH: usize = 500;
17const MAX_BODY_LENGTH: usize = 500_000;
18const MAX_EXCERPT_LENGTH: usize = 2000;
19
20fn validate_content_input(title: &str, body: &str, excerpt: Option<&str>) -> Result<()> {
21    if title.is_empty() {
22        bail!("Title cannot be empty");
23    }
24    if title.len() > MAX_TITLE_LENGTH {
25        bail!("Title must be {} characters or less", MAX_TITLE_LENGTH);
26    }
27    if body.len() > MAX_BODY_LENGTH {
28        bail!(
29            "Content body must be {} characters or less",
30            MAX_BODY_LENGTH
31        );
32    }
33    if let Some(exc) = excerpt {
34        if exc.len() > MAX_EXCERPT_LENGTH {
35            bail!("Excerpt must be {} characters or less", MAX_EXCERPT_LENGTH);
36        }
37    }
38    Ok(())
39}
40
41pub fn create_content(
42    db: &Database,
43    input: CreateContent,
44    author_id: Option<i64>,
45    excerpt_length: usize,
46) -> Result<i64> {
47    validate_content_input(&input.title, &input.body_markdown, input.excerpt.as_deref())?;
48
49    let renderer = MarkdownRenderer::new();
50    let slug = input.slug.unwrap_or_else(|| generate_slug(&input.title));
51
52    if !validate_slug(&slug) {
53        bail!(
54            "Invalid slug: must be 1-200 characters, lowercase letters, numbers, and hyphens only"
55        );
56    }
57
58    let mut conn = db.get()?;
59    let tx = conn.transaction()?;
60
61    // Check for slug uniqueness within transaction to prevent race conditions
62    let existing: Option<i64> = tx
63        .query_row("SELECT id FROM content WHERE slug = ?", [&slug], |row| {
64            row.get(0)
65        })
66        .ok();
67    if existing.is_some() {
68        bail!("A post or page with the slug '{}' already exists", slug);
69    }
70
71    // Process snippet shortcodes before rendering
72    let processed_markdown = process_snippet_shortcodes(db, &input.body_markdown);
73    let body_html = renderer.render(&processed_markdown);
74    let excerpt = input.excerpt.or_else(|| {
75        if input.body_markdown.is_empty() {
76            None
77        } else {
78            Some(renderer.generate_excerpt(&input.body_markdown, excerpt_length))
79        }
80    });
81
82    let published_at = if input.status == ContentStatus::Published {
83        Some(chrono::Utc::now().to_rfc3339())
84    } else {
85        None
86    };
87
88    let scheduled_at = if input.status == ContentStatus::Scheduled {
89        match &input.scheduled_at {
90            Some(dt) if !dt.is_empty() => {
91                // Validate the timestamp format and ensure it's in the future
92                if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(dt) {
93                    if parsed <= chrono::Utc::now() {
94                        bail!("Scheduled time must be in the future");
95                    }
96                    Some(dt.clone())
97                } else if let Ok(parsed) =
98                    chrono::NaiveDateTime::parse_from_str(dt, "%Y-%m-%dT%H:%M")
99                {
100                    // Handle datetime-local format from HTML forms
101                    let utc_time = parsed.and_utc();
102                    if utc_time <= chrono::Utc::now() {
103                        bail!("Scheduled time must be in the future");
104                    }
105                    Some(utc_time.to_rfc3339())
106                } else {
107                    bail!("Invalid scheduled_at timestamp format. Use ISO 8601 format (e.g., 2024-01-15T10:30:00Z)");
108                }
109            }
110            _ => bail!("Scheduled status requires a scheduled_at timestamp"),
111        }
112    } else {
113        None
114    };
115
116    // Calculate reading time and merge with provided metadata
117    let reading_time = renderer.calculate_reading_time(&input.body_markdown);
118    let mut metadata = input.metadata.unwrap_or(serde_json::json!({}));
119    metadata["reading_time_minutes"] = serde_json::json!(reading_time);
120
121    tx.execute(
122        r#"
123        INSERT INTO content (slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata)
124        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
125        "#,
126        (
127            &slug,
128            &input.title,
129            input.content_type.to_string(),
130            &input.body_markdown,
131            &body_html,
132            &excerpt,
133            &input.featured_image,
134            input.status.to_string(),
135            &scheduled_at,
136            &published_at,
137            author_id,
138            serde_json::to_string(&metadata)?,
139        ),
140    )?;
141
142    let content_id = tx.last_insert_rowid();
143
144    for tag_name in input.tags {
145        let tag_slug = generate_slug(&tag_name);
146        tx.execute(
147            "INSERT OR IGNORE INTO tags (name, slug) VALUES (?, ?)",
148            (&tag_name, &tag_slug),
149        )?;
150        tx.execute(
151            "INSERT OR IGNORE INTO content_tags (content_id, tag_id) SELECT ?, id FROM tags WHERE slug = ?",
152            (content_id, &tag_slug),
153        )?;
154    }
155
156    tx.commit()?;
157    Ok(content_id)
158}
159
160pub fn update_content(
161    db: &Database,
162    id: i64,
163    input: UpdateContent,
164    _excerpt_length: usize, // Preserved for API compatibility; excerpt is now only updated when explicitly provided
165    user_id: Option<i64>,
166    version_retention: usize,
167) -> Result<()> {
168    // Create a version snapshot BEFORE applying changes
169    if let Err(e) = super::versions::create_version(db, id, user_id) {
170        tracing::warn!("Failed to create version snapshot: {}", e);
171        // Continue with update even if versioning fails
172    }
173
174    let renderer = MarkdownRenderer::new();
175    let mut conn = db.get()?;
176
177    let current: Content = conn.query_row(
178        "SELECT id, slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata, created_at, updated_at FROM content WHERE id = ?",
179        [id],
180        row_to_content,
181    )?;
182
183    let title = input.title.unwrap_or(current.title);
184    let original_slug = current.slug.clone();
185    let slug = input.slug.unwrap_or(current.slug);
186    let body_markdown = input.body_markdown.unwrap_or(current.body_markdown);
187
188    validate_content_input(&title, &body_markdown, input.excerpt.as_deref())?;
189
190    if !validate_slug(&slug) {
191        bail!(
192            "Invalid slug: must be 1-200 characters, lowercase letters, numbers, and hyphens only"
193        );
194    }
195
196    // Check for slug uniqueness (excluding current content) for better error messages
197    if slug != original_slug {
198        let existing: Option<i64> = conn
199            .query_row("SELECT id FROM content WHERE slug = ?", [&slug], |row| {
200                row.get(0)
201            })
202            .ok();
203        if existing.is_some() {
204            bail!("A post or page with the slug '{}' already exists", slug);
205        }
206    }
207
208    // Process snippet shortcodes before rendering
209    let processed_markdown = process_snippet_shortcodes(db, &body_markdown);
210    let body_html = renderer.render(&processed_markdown);
211    // Only regenerate excerpt if explicitly provided in input, otherwise keep current
212    let excerpt = match input.excerpt {
213        Some(new_excerpt) => Some(new_excerpt),
214        None => current.excerpt, // Preserve existing excerpt
215    };
216    let featured_image = input.featured_image.or(current.featured_image);
217    let status = input.status.unwrap_or(current.status);
218
219    // Calculate reading time and merge with provided metadata
220    let reading_time = renderer.calculate_reading_time(&body_markdown);
221    let mut metadata = current.metadata;
222    if let Some(input_meta) = input.metadata {
223        if let (Some(base), Some(updates)) = (metadata.as_object_mut(), input_meta.as_object()) {
224            for (key, value) in updates {
225                base.insert(key.clone(), value.clone());
226            }
227        }
228    }
229    metadata["reading_time_minutes"] = serde_json::json!(reading_time);
230
231    let published_at = if status == ContentStatus::Published && current.published_at.is_none() {
232        Some(chrono::Utc::now().to_rfc3339())
233    } else {
234        current.published_at
235    };
236
237    let scheduled_at = if status == ContentStatus::Scheduled {
238        match input.scheduled_at.or(current.scheduled_at) {
239            Some(dt) if !dt.is_empty() => {
240                // Validate the timestamp format and ensure it's in the future
241                if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&dt) {
242                    if parsed <= chrono::Utc::now() {
243                        bail!("Scheduled time must be in the future");
244                    }
245                    Some(dt)
246                } else if let Ok(parsed) =
247                    chrono::NaiveDateTime::parse_from_str(&dt, "%Y-%m-%dT%H:%M")
248                {
249                    // Handle datetime-local format from HTML forms
250                    let utc_time = parsed.and_utc();
251                    if utc_time <= chrono::Utc::now() {
252                        bail!("Scheduled time must be in the future");
253                    }
254                    Some(utc_time.to_rfc3339())
255                } else {
256                    bail!("Invalid scheduled_at timestamp format. Use ISO 8601 format (e.g., 2024-01-15T10:30:00Z)");
257                }
258            }
259            _ => bail!("Scheduled status requires a scheduled_at timestamp"),
260        }
261    } else {
262        None
263    };
264
265    let tx = conn.transaction()?;
266
267    tx.execute(
268        r#"
269        UPDATE content SET slug = ?, title = ?, body_markdown = ?, body_html = ?, excerpt = ?, featured_image = ?, status = ?, scheduled_at = ?, published_at = ?, metadata = ?
270        WHERE id = ?
271        "#,
272        (
273            &slug,
274            &title,
275            &body_markdown,
276            &body_html,
277            &excerpt,
278            &featured_image,
279            status.to_string(),
280            &scheduled_at,
281            &published_at,
282            serde_json::to_string(&metadata)?,
283            id,
284        ),
285    )?;
286
287    if let Some(tags) = input.tags {
288        tx.execute("DELETE FROM content_tags WHERE content_id = ?", [id])?;
289        for tag_name in tags {
290            let tag_slug = generate_slug(&tag_name);
291            tx.execute(
292                "INSERT OR IGNORE INTO tags (name, slug) VALUES (?, ?)",
293                (&tag_name, &tag_slug),
294            )?;
295            tx.execute(
296                "INSERT OR IGNORE INTO content_tags (content_id, tag_id) SELECT ?, id FROM tags WHERE slug = ?",
297                (id, &tag_slug),
298            )?;
299        }
300    }
301
302    tx.commit()?;
303
304    // Cleanup old versions based on retention policy
305    if version_retention > 0 {
306        if let Err(e) = super::versions::cleanup_old_versions(db, id, version_retention) {
307            tracing::warn!("Failed to cleanup old versions: {}", e);
308        }
309    }
310
311    Ok(())
312}
313
314pub fn delete_content(db: &Database, id: i64) -> Result<()> {
315    let conn = db.get()?;
316    conn.execute("DELETE FROM content WHERE id = ?", [id])?;
317    let _ = crate::services::tags::cleanup_orphaned_tags(db);
318    Ok(())
319}
320
321pub fn get_content_by_id(db: &Database, id: i64) -> Result<Option<ContentWithTags>> {
322    let conn = db.get()?;
323    let content: Option<Content> = conn
324        .query_row(
325            "SELECT id, slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata, created_at, updated_at FROM content WHERE id = ?",
326            [id],
327            row_to_content,
328        )
329        .ok();
330
331    match content {
332        Some(c) => Ok(Some(enrich_content(db, c)?)),
333        None => Ok(None),
334    }
335}
336
337pub fn get_content_by_slug(db: &Database, slug: &str) -> Result<Option<ContentWithTags>> {
338    let conn = db.get()?;
339    let content: Option<Content> = conn
340        .query_row(
341            "SELECT id, slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata, created_at, updated_at FROM content WHERE slug = ?",
342            [slug],
343            row_to_content,
344        )
345        .ok();
346
347    match content {
348        Some(c) => Ok(Some(enrich_content(db, c)?)),
349        None => Ok(None),
350    }
351}
352
353pub fn list_content(
354    db: &Database,
355    content_type: Option<ContentType>,
356    status: Option<ContentStatus>,
357    limit: usize,
358    offset: usize,
359) -> Result<Vec<ContentSummary>> {
360    let conn = db.get()?;
361
362    let mut sql = String::from(
363        "SELECT id, slug, title, content_type, excerpt, status, scheduled_at, published_at, created_at FROM content WHERE 1=1",
364    );
365    let mut params: Vec<String> = Vec::new();
366
367    if let Some(ct) = content_type {
368        sql.push_str(" AND content_type = ?");
369        params.push(ct.to_string());
370    }
371    if let Some(s) = status {
372        sql.push_str(" AND status = ?");
373        params.push(s.to_string());
374    }
375
376    sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
377
378    let mut stmt = conn.prepare(&sql)?;
379
380    let param_refs: Vec<&dyn rusqlite::ToSql> = params
381        .iter()
382        .map(|s| s as &dyn rusqlite::ToSql)
383        .chain(std::iter::once(&limit as &dyn rusqlite::ToSql))
384        .chain(std::iter::once(&offset as &dyn rusqlite::ToSql))
385        .collect();
386
387    let content = stmt
388        .query_map(param_refs.as_slice(), |row| {
389            Ok(ContentSummary {
390                id: row.get(0)?,
391                slug: row.get(1)?,
392                title: row.get(2)?,
393                content_type: row
394                    .get::<_, String>(3)?
395                    .parse()
396                    .unwrap_or(ContentType::Post),
397                excerpt: row.get(4)?,
398                status: row
399                    .get::<_, String>(5)?
400                    .parse()
401                    .unwrap_or(ContentStatus::Draft),
402                scheduled_at: row.get(6)?,
403                published_at: row.get(7)?,
404                created_at: row.get(8)?,
405            })
406        })?
407        .collect::<Result<Vec<_>, _>>()?;
408
409    Ok(content)
410}
411
412pub fn list_published_content(
413    db: &Database,
414    content_type: ContentType,
415    limit: usize,
416    offset: usize,
417) -> Result<Vec<ContentWithTags>> {
418    let conn = db.get()?;
419    let mut stmt = conn.prepare(
420        "SELECT id, slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata, created_at, updated_at
421         FROM content WHERE content_type = ? AND status = 'published' ORDER BY published_at DESC LIMIT ? OFFSET ?",
422    )?;
423
424    let content = stmt
425        .query_map((content_type.to_string(), limit, offset), row_to_content)?
426        .collect::<Result<Vec<_>, _>>()?;
427
428    enrich_content_batch(db, content)
429}
430
431pub fn count_content(
432    db: &Database,
433    content_type: Option<ContentType>,
434    status: Option<ContentStatus>,
435) -> Result<i64> {
436    let conn = db.get()?;
437    let mut sql = String::from("SELECT COUNT(*) FROM content WHERE 1=1");
438    let mut params: Vec<String> = Vec::new();
439
440    if let Some(ct) = content_type {
441        sql.push_str(" AND content_type = ?");
442        params.push(ct.to_string());
443    }
444    if let Some(s) = status {
445        sql.push_str(" AND status = ?");
446        params.push(s.to_string());
447    }
448
449    let param_refs: Vec<&dyn rusqlite::ToSql> =
450        params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
451    let count: i64 = conn.query_row(&sql, param_refs.as_slice(), |row| row.get(0))?;
452    Ok(count)
453}
454
455pub fn ensure_metadata_defaults(mut metadata: serde_json::Value) -> serde_json::Value {
456    // Ensure custom code fields have default values for template compatibility
457    // use_custom_code: "none" (default), "only" (custom code only), "both" (markdown + custom)
458    if metadata.get("use_custom_code").is_none() {
459        metadata["use_custom_code"] = serde_json::json!("none");
460    } else if let Some(val) = metadata.get("use_custom_code") {
461        // Normalize empty string to "none" for consistency
462        if val.as_str() == Some("") {
463            metadata["use_custom_code"] = serde_json::json!("none");
464        }
465    }
466    if metadata.get("custom_html").is_none() {
467        metadata["custom_html"] = serde_json::json!("");
468    }
469    if metadata.get("custom_css").is_none() {
470        metadata["custom_css"] = serde_json::json!("");
471    }
472    if metadata.get("custom_js").is_none() {
473        metadata["custom_js"] = serde_json::json!("");
474    }
475    metadata
476}
477
478fn row_to_content(row: &rusqlite::Row) -> rusqlite::Result<Content> {
479    let raw_metadata: serde_json::Value =
480        serde_json::from_str(&row.get::<_, String>(12)?).unwrap_or(serde_json::json!({}));
481    let metadata = ensure_metadata_defaults(raw_metadata);
482
483    Ok(Content {
484        id: row.get(0)?,
485        slug: row.get(1)?,
486        title: row.get(2)?,
487        content_type: row
488            .get::<_, String>(3)?
489            .parse()
490            .unwrap_or(ContentType::Post),
491        body_markdown: row.get(4)?,
492        body_html: row.get(5)?,
493        excerpt: row.get(6)?,
494        featured_image: row.get(7)?,
495        status: row
496            .get::<_, String>(8)?
497            .parse()
498            .unwrap_or(ContentStatus::Draft),
499        scheduled_at: row.get(9)?,
500        published_at: row.get(10)?,
501        author_id: row.get(11)?,
502        metadata,
503        created_at: row.get(13)?,
504        updated_at: row.get(14)?,
505    })
506}
507
508fn enrich_content(db: &Database, content: Content) -> Result<ContentWithTags> {
509    let conn = db.get()?;
510
511    let mut tag_stmt = conn.prepare(
512        "SELECT t.id, t.name, t.slug, t.created_at FROM tags t JOIN content_tags ct ON t.id = ct.tag_id WHERE ct.content_id = ?",
513    )?;
514    let tags: Vec<Tag> = tag_stmt
515        .query_map([content.id], |row| {
516            Ok(Tag {
517                id: row.get(0)?,
518                name: row.get(1)?,
519                slug: row.get(2)?,
520                created_at: row.get(3)?,
521            })
522        })?
523        .collect::<Result<Vec<_>, _>>()?;
524
525    let author = content.author_id.and_then(|aid| {
526        conn.query_row(
527            "SELECT id, username FROM users WHERE id = ?",
528            [aid],
529            |row| {
530                Ok(UserSummary {
531                    id: row.get(0)?,
532                    username: row.get(1)?,
533                })
534            },
535        )
536        .ok()
537    });
538
539    Ok(ContentWithTags {
540        content,
541        tags,
542        author,
543    })
544}
545
546/// Batch enrich multiple content items to avoid N+1 queries.
547/// Fetches all tags and authors in bulk queries instead of per-item.
548fn enrich_content_batch(db: &Database, contents: Vec<Content>) -> Result<Vec<ContentWithTags>> {
549    if contents.is_empty() {
550        return Ok(vec![]);
551    }
552
553    let conn = db.get()?;
554
555    // Collect all content IDs and author IDs
556    let content_ids: Vec<i64> = contents.iter().map(|c| c.id).collect();
557    let author_ids: Vec<i64> = contents
558        .iter()
559        .filter_map(|c| c.author_id)
560        .collect::<std::collections::HashSet<_>>()
561        .into_iter()
562        .collect();
563
564    // Batch fetch all tags for all content items
565    let placeholders: String = content_ids
566        .iter()
567        .map(|_| "?")
568        .collect::<Vec<_>>()
569        .join(",");
570    let tag_sql = format!(
571        "SELECT ct.content_id, t.id, t.name, t.slug, t.created_at
572         FROM tags t
573         JOIN content_tags ct ON t.id = ct.tag_id
574         WHERE ct.content_id IN ({})",
575        placeholders
576    );
577
578    let mut tag_stmt = conn.prepare(&tag_sql)?;
579    let params: Vec<&dyn rusqlite::ToSql> = content_ids
580        .iter()
581        .map(|id| id as &dyn rusqlite::ToSql)
582        .collect();
583
584    let mut tags_by_content: std::collections::HashMap<i64, Vec<Tag>> =
585        std::collections::HashMap::new();
586
587    let tag_rows = tag_stmt.query_map(params.as_slice(), |row| {
588        Ok((
589            row.get::<_, i64>(0)?,
590            Tag {
591                id: row.get(1)?,
592                name: row.get(2)?,
593                slug: row.get(3)?,
594                created_at: row.get(4)?,
595            },
596        ))
597    })?;
598
599    for row in tag_rows {
600        let (content_id, tag) = row?;
601        tags_by_content.entry(content_id).or_default().push(tag);
602    }
603
604    // Batch fetch all authors
605    let mut authors_by_id: std::collections::HashMap<i64, UserSummary> =
606        std::collections::HashMap::new();
607
608    if !author_ids.is_empty() {
609        let author_placeholders: String =
610            author_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
611        let author_sql = format!(
612            "SELECT id, username FROM users WHERE id IN ({})",
613            author_placeholders
614        );
615
616        let mut author_stmt = conn.prepare(&author_sql)?;
617        let author_params: Vec<&dyn rusqlite::ToSql> = author_ids
618            .iter()
619            .map(|id| id as &dyn rusqlite::ToSql)
620            .collect();
621
622        let author_rows = author_stmt.query_map(author_params.as_slice(), |row| {
623            Ok(UserSummary {
624                id: row.get(0)?,
625                username: row.get(1)?,
626            })
627        })?;
628
629        for row in author_rows {
630            let author = row?;
631            authors_by_id.insert(author.id, author);
632        }
633    }
634
635    // Build the enriched content
636    let result: Vec<ContentWithTags> = contents
637        .into_iter()
638        .map(|content| {
639            let tags = tags_by_content.remove(&content.id).unwrap_or_default();
640            let author = content
641                .author_id
642                .and_then(|aid| authors_by_id.get(&aid).cloned());
643            ContentWithTags {
644                content,
645                tags,
646                author,
647            }
648        })
649        .collect();
650
651    Ok(result)
652}
653
654pub fn publish_scheduled(db: &Database) -> Result<usize> {
655    let mut conn = db.get()?;
656    let now = chrono::Utc::now().to_rfc3339();
657
658    let tx = conn.transaction()?;
659
660    let mut stmt = tx.prepare(
661        "SELECT id FROM content WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?"
662    )?;
663    let ids: Vec<i64> = stmt
664        .query_map([&now], |row| row.get(0))?
665        .collect::<Result<Vec<_>, _>>()?;
666    drop(stmt);
667
668    if ids.is_empty() {
669        return Ok(0);
670    }
671
672    for id in &ids {
673        tx.execute(
674            "UPDATE content SET status = 'published', published_at = ?, scheduled_at = NULL WHERE id = ?",
675            (&now, id),
676        )?;
677        tracing::info!("Auto-published scheduled content id={}", id);
678    }
679
680    tx.commit()?;
681    Ok(ids.len())
682}
683
684/// Re-render all content HTML from markdown.
685/// Useful after updating the markdown renderer to apply changes to existing content.
686pub fn rerender_all_content(db: &Database) -> Result<usize> {
687    let renderer = super::markdown::MarkdownRenderer::new();
688    let mut conn = db.get()?;
689
690    // Get all content IDs and markdown
691    let items: Vec<(i64, String)> = {
692        let mut stmt = conn.prepare("SELECT id, body_markdown FROM content")?;
693        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
694        rows.collect::<Result<Vec<_>, _>>()?
695    };
696
697    let count = items.len();
698
699    let tx = conn.transaction()?;
700    for (id, markdown) in items {
701        let processed = process_snippet_shortcodes(db, &markdown);
702        let html = renderer.render(&processed);
703        tx.execute("UPDATE content SET body_html = ? WHERE id = ?", (&html, id))?;
704    }
705    tx.commit()?;
706
707    Ok(count)
708}
709
710/// Process `[snippet slug="..."]` shortcodes by replacing them with the snippet's rendered HTML.
711/// Snippets are resolved from the database. Unknown slugs are left as-is.
712/// To prevent infinite recursion, snippet content is not recursively processed for nested snippets.
713pub fn process_snippet_shortcodes(db: &Database, markdown: &str) -> String {
714    SNIPPET_REGEX
715        .replace_all(markdown, |caps: &regex::Captures| {
716            let slug = &caps[1];
717            match get_content_by_slug(db, slug) {
718                Ok(Some(content_with_tags))
719                    if content_with_tags.content.content_type == ContentType::Snippet =>
720                {
721                    // Return the snippet's markdown body so it gets rendered inline
722                    content_with_tags.content.body_markdown
723                }
724                _ => caps[0].to_string(), // Leave unresolved shortcodes as-is
725            }
726        })
727        .to_string()
728}