Skip to main content

ssg_core/
lib.rs

1#![forbid(unsafe_code)]
2// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! # ssg-core — Platform-independent SSG compilation pipeline
6//!
7//! This crate contains the pure-logic core of SSG, with no system
8//! dependencies (`rayon`, `http-handle`). It compiles to
9//! `wasm32-wasi` and `wasm32-unknown-unknown` (via `wasm-bindgen`).
10//!
11//! ## Features
12//!
13//! - Markdown → HTML compilation (pulldown-cmark with GFM extensions)
14//! - Frontmatter parsing (TOML/JSON/YAML)
15//! - Template rendering (when `minijinja` is enabled)
16//! - Shortcode expansion
17//! - SEO metadata generation
18//! - Search index generation
19
20pub mod content_provider;
21pub mod isr_manifest;
22
23pub use content_provider::{
24    ContentProvider, FsContentProvider, MemoryContentProvider, ProviderError,
25    ProviderResult,
26};
27pub use isr_manifest::{
28    build_entry, hash_sources, CachePolicy, Manifest, ManifestEntry,
29    DEFAULT_SWR, DEFAULT_S_MAXAGE, MANIFEST_VERSION,
30};
31
32use std::collections::HashMap;
33use std::fmt;
34
35/// The error type for ssg-core operations.
36///
37/// # Examples
38///
39/// ```
40/// use ssg_core::Error;
41///
42/// let err = Error::InvalidSlug { input: "@@@".to_string() };
43/// assert!(err.to_string().contains("Invalid slug input"));
44/// ```
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Error {
47    /// TOML/YAML/JSON parsing failures.
48    FrontmatterParse {
49        /// The syntax format (e.g. "toml", "yaml", "json") or parse error detail.
50        syntax: String,
51    },
52    /// Markdown rendering bugs.
53    MarkdownCompile {
54        /// Detail about what failed.
55        source: String,
56    },
57    /// Slugification layout validation failures.
58    InvalidSlug {
59        /// The invalid input string.
60        input: String,
61    },
62}
63
64impl fmt::Display for Error {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::FrontmatterParse { syntax } => {
68                write!(f, "Frontmatter parse error: {syntax}")
69            }
70            Self::MarkdownCompile { source } => {
71                write!(f, "Markdown compilation error: {source}")
72            }
73            Self::InvalidSlug { input } => {
74                write!(f, "Invalid slug input: {input}")
75            }
76        }
77    }
78}
79
80impl std::error::Error for Error {}
81
82/// Specialized Result type for ssg-core operations.
83pub type Result<T> = std::result::Result<T, Error>;
84
85/// Compile a Markdown string to HTML.
86///
87/// Supports GitHub Flavored Markdown: tables, strikethrough, task lists.
88///
89/// # Example
90///
91/// ```
92/// let html = ssg_core::compile_markdown("# Hello\n\nWorld");
93/// assert!(html.contains("<h1>Hello</h1>"));
94/// assert!(html.contains("<p>World</p>"));
95/// ```
96#[must_use]
97pub fn compile_markdown(input: &str) -> String {
98    use pulldown_cmark::{html, Options, Parser};
99
100    let options = Options::ENABLE_TABLES
101        | Options::ENABLE_STRIKETHROUGH
102        | Options::ENABLE_TASKLISTS;
103
104    let parser = Parser::new_ext(input, options);
105    let mut html_output = String::with_capacity(input.len() * 2);
106    html::push_html(&mut html_output, parser);
107    html_output
108}
109
110/// Parse frontmatter from a Markdown file.
111///
112/// Supports TOML (`+++`), YAML (`---`), and JSON (`{`) delimiters.
113/// Returns `(frontmatter_map, body_without_frontmatter)`.
114///
115/// # Example
116///
117/// ```
118/// let input = "---\ntitle: Hello\n---\n# Body";
119/// let (fm, body) = ssg_core::parse_frontmatter(input);
120/// assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
121/// assert!(body.contains("# Body"));
122/// ```
123pub fn parse_frontmatter(
124    input: &str,
125) -> (HashMap<String, serde_json::Value>, String) {
126    let trimmed = input.trim_start();
127
128    // TOML frontmatter: +++...+++
129    if let Some(after) = trimmed.strip_prefix("+++") {
130        if let Some(end) = after.find("+++") {
131            let fm_str = &after[..end];
132            let body = &after[end + 3..];
133            if let Ok(value) = toml::from_str::<serde_json::Value>(fm_str) {
134                if let Some(map) = value.as_object() {
135                    return (
136                        map.iter()
137                            .map(|(k, v)| (k.clone(), v.clone()))
138                            .collect(),
139                        body.to_string(),
140                    );
141                }
142            }
143            return (HashMap::new(), body.to_string());
144        }
145    }
146
147    // YAML frontmatter: ---...---
148    if let Some(after) = trimmed.strip_prefix("---") {
149        if let Some(end) = after.find("---") {
150            let fm_str = &after[..end];
151            let body = &after[end + 3..];
152            match serde_yaml_ng::from_str::<serde_json::Value>(fm_str) {
153                Ok(serde_json::Value::Object(map)) => {
154                    return (map.into_iter().collect(), body.to_string());
155                }
156                Ok(_) => {
157                    // Top-level non-mapping (e.g. a bare list or scalar)
158                    // — preserve the body but emit no globals.
159                    return (HashMap::new(), body.to_string());
160                }
161                Err(e) => {
162                    log::warn!("YAML frontmatter parse error: {e}");
163                    return (HashMap::new(), body.to_string());
164                }
165            }
166        }
167    }
168
169    // JSON frontmatter: {...}
170    if trimmed.starts_with('{') {
171        // Find matching closing brace
172        let mut depth = 0;
173        let mut end = None;
174        for (i, c) in trimmed.char_indices() {
175            match c {
176                '{' => depth += 1,
177                '}' => {
178                    depth -= 1;
179                    if depth == 0 {
180                        end = Some(i + 1);
181                        break;
182                    }
183                }
184                _ => {}
185            }
186        }
187        if let Some(end_pos) = end {
188            let fm_str = &trimmed[..end_pos];
189            let body = &trimmed[end_pos..];
190            if let Ok(map) = serde_json::from_str::<
191                HashMap<String, serde_json::Value>,
192            >(fm_str)
193            {
194                return (map, body.to_string());
195            }
196        }
197    }
198
199    (HashMap::new(), input.to_string())
200}
201
202/// Compile a complete page: parse frontmatter, render Markdown to HTML.
203///
204/// Returns `(frontmatter, html_body)`.
205///
206/// # Errors
207/// Currently infallible — returns `Ok` for every input. The `Result`
208/// signature is preserved so that future stricter validation can
209/// surface failures without a breaking API change.
210///
211/// # Examples
212///
213/// ```
214/// let input = "---\ntitle: Test\n---\n# Heading";
215/// let (fm, html) = ssg_core::compile_page(input).unwrap();
216/// assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
217/// assert!(html.contains("<h1>Heading</h1>"));
218/// ```
219pub fn compile_page(
220    input: &str,
221) -> Result<(HashMap<String, serde_json::Value>, String)> {
222    let (frontmatter, body) = parse_frontmatter(input);
223    let html = compile_markdown(&body);
224    Ok((frontmatter, html))
225}
226
227/// Generate a search index entry from HTML content.
228///
229/// # Examples
230///
231/// ```
232/// let entry = ssg_core::SearchEntry {
233///     title: "Hi".to_string(),
234///     url: "/".to_string(),
235///     content: "hello".to_string(),
236/// };
237/// let json = serde_json::to_string(&entry).unwrap();
238/// assert!(json.contains("\"title\":\"Hi\""));
239/// ```
240#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
241pub struct SearchEntry {
242    /// Page title.
243    pub title: String,
244    /// Page URL.
245    pub url: String,
246    /// Plain text content for search matching.
247    pub content: String,
248}
249
250/// Strip HTML tags from a string (simple implementation).
251///
252/// # Examples
253///
254/// ```
255/// let plain = ssg_core::strip_html_tags("<p>Hello <b>world</b></p>");
256/// assert_eq!(plain, "Hello world");
257/// ```
258#[must_use]
259pub fn strip_html_tags(html: &str) -> String {
260    let mut result = String::with_capacity(html.len());
261    let mut in_tag = false;
262
263    for c in html.chars() {
264        match c {
265            '<' => in_tag = true,
266            '>' => in_tag = false,
267            _ if !in_tag => result.push(c),
268            _ => {}
269        }
270    }
271
272    result
273}
274
275/// Build a search index entry from HTML content and metadata.
276///
277/// # Examples
278///
279/// ```
280/// let entry = ssg_core::build_search_entry(
281///     "Welcome",
282///     "/index.html",
283///     "<p>Hello <b>world</b></p>",
284/// );
285/// assert_eq!(entry.title, "Welcome");
286/// assert_eq!(entry.url, "/index.html");
287/// assert_eq!(entry.content, "Hello world");
288/// ```
289#[must_use]
290pub fn build_search_entry(title: &str, url: &str, html: &str) -> SearchEntry {
291    let content = strip_html_tags(html);
292    // Collapse whitespace for compact index
293    let content: String =
294        content.split_whitespace().collect::<Vec<_>>().join(" ");
295    SearchEntry {
296        title: title.to_string(),
297        url: url.to_string(),
298        content,
299    }
300}
301
302/// Estimates reading time in minutes from text content.
303///
304/// Uses 200 words-per-minute average, with a minimum of 1 minute.
305///
306/// # Examples
307///
308/// ```
309/// assert_eq!(ssg_core::reading_time("a short article"), 1);
310/// let long = "word ".repeat(600);
311/// assert_eq!(ssg_core::reading_time(&long), 3);
312/// ```
313#[must_use]
314pub fn reading_time(text: &str) -> usize {
315    (text.split_whitespace().count() / 200).max(1)
316}
317
318/// Converts a string to a URL-safe slug.
319///
320/// Lowercases ASCII letters, replaces non-alphanumeric runs with a
321/// single `-`, and trims leading/trailing separators.
322///
323/// # Examples
324///
325/// ```
326/// assert_eq!(ssg_core::slugify("Hello World!"), "hello-world");
327/// assert_eq!(ssg_core::slugify("Rust & Web"), "rust-web");
328/// assert_eq!(ssg_core::slugify("--leading--"), "leading");
329/// ```
330#[must_use]
331pub fn slugify(input: &str) -> String {
332    input
333        .to_lowercase()
334        .chars()
335        .map(|c| if c.is_alphanumeric() { c } else { '-' })
336        .collect::<String>()
337        .split('-')
338        .filter(|s| !s.is_empty())
339        .collect::<Vec<_>>()
340        .join("-")
341}
342
343#[cfg(test)]
344#[allow(clippy::unwrap_used)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn compile_markdown_basic() {
350        let html = compile_markdown("# Hello\n\nParagraph.");
351        assert!(html.contains("<h1>Hello</h1>"));
352        assert!(html.contains("<p>Paragraph.</p>"));
353    }
354
355    #[test]
356    fn compile_markdown_gfm_tables() {
357        let input = "| A | B |\n|---|---|\n| 1 | 2 |";
358        let html = compile_markdown(input);
359        assert!(html.contains("<table>"));
360    }
361
362    #[test]
363    fn compile_markdown_strikethrough() {
364        let html = compile_markdown("~~deleted~~");
365        assert!(html.contains("<del>deleted</del>"));
366    }
367
368    #[test]
369    fn parse_frontmatter_yaml() {
370        let (fm, body) = parse_frontmatter(
371            "---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
372        );
373        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
374        assert!(body.contains("# Body"));
375    }
376
377    #[test]
378    fn parse_frontmatter_toml() {
379        let (fm, body) =
380            parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
381        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
382        assert!(body.contains("# Body"));
383    }
384
385    #[test]
386    fn parse_frontmatter_json() {
387        let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
388        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
389        assert!(body.contains("# Body"));
390    }
391
392    #[test]
393    fn parse_frontmatter_none() {
394        let (fm, body) = parse_frontmatter("Just content");
395        assert!(fm.is_empty());
396        assert_eq!(body, "Just content");
397    }
398
399    #[test]
400    fn compile_page_full() {
401        let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
402        let (fm, html) = compile_page(input).unwrap();
403        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
404        assert!(html.contains("<h1>Hello</h1>"));
405    }
406
407    #[test]
408    fn strip_html_tags_basic() {
409        assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
410    }
411
412    #[test]
413    fn strip_html_tags_empty() {
414        assert_eq!(strip_html_tags(""), "");
415    }
416
417    #[test]
418    fn build_search_entry_strips_tags() {
419        let entry =
420            build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
421        assert_eq!(entry.title, "Title");
422        assert_eq!(entry.content, "Hello world");
423    }
424
425    #[test]
426    fn reading_time_short() {
427        assert_eq!(reading_time("one two three"), 1);
428    }
429
430    #[test]
431    fn reading_time_long() {
432        let text = "word ".repeat(600);
433        assert_eq!(reading_time(&text), 3);
434    }
435
436    #[test]
437    fn slugify_basic() {
438        assert_eq!(slugify("Hello World!"), "hello-world");
439        assert_eq!(slugify("Rust & Web"), "rust-web");
440    }
441
442    #[test]
443    fn error_display_frontmatter_parse_variant() {
444        let e = Error::FrontmatterParse {
445            syntax: "yaml mismatch".to_string(),
446        };
447        let s = format!("{e}");
448        assert!(s.contains("Frontmatter parse error"));
449        assert!(s.contains("yaml mismatch"));
450    }
451
452    #[test]
453    fn error_display_markdown_compile_variant() {
454        let e = Error::MarkdownCompile {
455            source: "broken markdown".to_string(),
456        };
457        let s = format!("{e}");
458        assert!(s.contains("Markdown compilation error"));
459        assert!(s.contains("broken markdown"));
460    }
461
462    #[test]
463    fn error_display_invalid_slug_variant() {
464        let e = Error::InvalidSlug {
465            input: "@@@".to_string(),
466        };
467        let s = format!("{e}");
468        assert!(s.contains("Invalid slug input"));
469        assert!(s.contains("@@@"));
470    }
471
472    #[test]
473    fn error_is_std_error_trait_object() {
474        // Smoke-tests the `impl std::error::Error for Error {}` block.
475        let e: Box<dyn std::error::Error> = Box::new(Error::InvalidSlug {
476            input: "x".to_string(),
477        });
478        assert!(!e.to_string().is_empty());
479        // No source by default.
480        assert!(std::error::Error::source(&*e).is_none());
481    }
482
483    #[test]
484    fn error_debug_impl_executes_for_each_variant() {
485        let e1 = Error::FrontmatterParse {
486            syntax: "a".to_string(),
487        };
488        let e2 = Error::MarkdownCompile {
489            source: "b".to_string(),
490        };
491        let e3 = Error::InvalidSlug {
492            input: "c".to_string(),
493        };
494        for e in [&e1, &e2, &e3] {
495            let s = format!("{e:?}");
496            assert!(!s.is_empty());
497        }
498    }
499
500    #[test]
501    fn search_entry_serialization_roundtrip() {
502        let e = SearchEntry {
503            title: "T".to_string(),
504            url: "/u".to_string(),
505            content: "C".to_string(),
506        };
507        let json = serde_json::to_string(&e).unwrap();
508        assert!(json.contains("\"title\":\"T\""));
509        let back: SearchEntry = serde_json::from_str(&json).unwrap();
510        assert_eq!(back.url, "/u");
511        assert_eq!(back.content, "C");
512        // Debug + Clone are derived; exercise them.
513        let _ = format!("{back:?}");
514        let _ = back.clone();
515    }
516
517    #[test]
518    fn compile_page_yields_empty_frontmatter_when_absent() {
519        let (fm, html) = compile_page("# Heading\n\nBody").unwrap();
520        assert!(fm.is_empty());
521        assert!(html.contains("<h1>Heading</h1>"));
522    }
523
524    #[test]
525    fn slugify_collapses_consecutive_separators() {
526        assert_eq!(slugify("foo!!!bar"), "foo-bar");
527        assert_eq!(slugify("--leading--"), "leading");
528    }
529
530    #[test]
531    fn slugify_empty_input_yields_empty() {
532        assert_eq!(slugify(""), "");
533        assert_eq!(slugify("???"), "");
534    }
535}