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    // Zero-copy core (issue #578, plan §4 3.1): the body is sliced out
127    // of `input` exactly once and materialised exactly once here — no
128    // per-branch `to_string()` and no metadata-map clone rebuilds.
129    let (map, body) = parse_frontmatter_borrowed(input);
130    (map, body.to_string())
131}
132
133/// Borrowed-body core of [`parse_frontmatter`].
134///
135/// Returns the metadata map by *moving* parsed entries (never cloning
136/// them) and the body as a slice of `input`, leaving the single owned
137/// materialisation to the public wrapper (issue #578, plan §4 3.1).
138fn parse_frontmatter_borrowed(
139    input: &str,
140) -> (HashMap<String, serde_json::Value>, &str) {
141    let trimmed = input.trim_start();
142
143    // TOML frontmatter: +++...+++
144    if let Some(after) = trimmed.strip_prefix("+++") {
145        if let Some(end) = after.find("+++") {
146            let fm_str = &after[..end];
147            let body = &after[end + 3..];
148            if let Ok(serde_json::Value::Object(map)) =
149                toml::from_str::<serde_json::Value>(fm_str)
150            {
151                // Move the parsed entries into the final map — the
152                // previous `(k.clone(), v.clone())` rebuild is gone.
153                return (map.into_iter().collect(), body);
154            }
155            return (HashMap::new(), body);
156        }
157    }
158
159    // YAML frontmatter: ---...---
160    if let Some(after) = trimmed.strip_prefix("---") {
161        if let Some(end) = after.find("---") {
162            let fm_str = &after[..end];
163            let body = &after[end + 3..];
164            match noyalib::from_str::<serde_json::Value>(fm_str) {
165                Ok(serde_json::Value::Object(map)) => {
166                    return (map.into_iter().collect(), body);
167                }
168                Ok(_) => {
169                    // Top-level non-mapping (e.g. a bare list or scalar)
170                    // — preserve the body but emit no globals.
171                    return (HashMap::new(), body);
172                }
173                Err(e) => {
174                    log::warn!("YAML frontmatter parse error: {e}");
175                    return (HashMap::new(), body);
176                }
177            }
178        }
179    }
180
181    // JSON frontmatter: {...}
182    if trimmed.starts_with('{') {
183        // Find matching closing brace
184        let mut depth = 0;
185        let mut end = None;
186        for (i, c) in trimmed.char_indices() {
187            match c {
188                '{' => depth += 1,
189                '}' => {
190                    depth -= 1;
191                    if depth == 0 {
192                        end = Some(i + 1);
193                        break;
194                    }
195                }
196                _ => {}
197            }
198        }
199        if let Some(end_pos) = end {
200            let fm_str = &trimmed[..end_pos];
201            let body = &trimmed[end_pos..];
202            if let Ok(map) = serde_json::from_str::<
203                HashMap<String, serde_json::Value>,
204            >(fm_str)
205            {
206                return (map, body);
207            }
208        }
209    }
210
211    (HashMap::new(), input)
212}
213
214/// Compile a complete page: parse frontmatter, render Markdown to HTML.
215///
216/// Returns `(frontmatter, html_body)`.
217///
218/// # Errors
219/// Currently infallible — returns `Ok` for every input. The `Result`
220/// signature is preserved so that future stricter validation can
221/// surface failures without a breaking API change.
222///
223/// # Examples
224///
225/// ```
226/// let input = "---\ntitle: Test\n---\n# Heading";
227/// let (fm, html) = ssg_core::compile_page(input).unwrap();
228/// assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
229/// assert!(html.contains("<h1>Heading</h1>"));
230/// ```
231pub fn compile_page(
232    input: &str,
233) -> Result<(HashMap<String, serde_json::Value>, String)> {
234    let (frontmatter, body) = parse_frontmatter(input);
235    let html = compile_markdown(&body);
236    Ok((frontmatter, html))
237}
238
239/// Generate a search index entry from HTML content.
240///
241/// # Examples
242///
243/// ```
244/// let entry = ssg_core::SearchEntry {
245///     title: "Hi".to_string(),
246///     url: "/".to_string(),
247///     content: "hello".to_string(),
248/// };
249/// let json = serde_json::to_string(&entry).unwrap();
250/// assert!(json.contains("\"title\":\"Hi\""));
251/// ```
252#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
253pub struct SearchEntry {
254    /// Page title.
255    pub title: String,
256    /// Page URL.
257    pub url: String,
258    /// Plain text content for search matching.
259    pub content: String,
260}
261
262/// Strip HTML tags from a string (simple implementation).
263///
264/// # Examples
265///
266/// ```
267/// let plain = ssg_core::strip_html_tags("<p>Hello <b>world</b></p>");
268/// assert_eq!(plain, "Hello world");
269/// ```
270#[must_use]
271pub fn strip_html_tags(html: &str) -> String {
272    let mut result = String::with_capacity(html.len());
273    let mut in_tag = false;
274
275    for c in html.chars() {
276        match c {
277            '<' => in_tag = true,
278            '>' => in_tag = false,
279            _ if !in_tag => result.push(c),
280            _ => {}
281        }
282    }
283
284    result
285}
286
287/// Build a search index entry from HTML content and metadata.
288///
289/// # Examples
290///
291/// ```
292/// let entry = ssg_core::build_search_entry(
293///     "Welcome",
294///     "/index.html",
295///     "<p>Hello <b>world</b></p>",
296/// );
297/// assert_eq!(entry.title, "Welcome");
298/// assert_eq!(entry.url, "/index.html");
299/// assert_eq!(entry.content, "Hello world");
300/// ```
301#[must_use]
302pub fn build_search_entry(title: &str, url: &str, html: &str) -> SearchEntry {
303    let content = strip_html_tags(html);
304    // Collapse whitespace for compact index
305    let content: String =
306        content.split_whitespace().collect::<Vec<_>>().join(" ");
307    SearchEntry {
308        title: title.to_string(),
309        url: url.to_string(),
310        content,
311    }
312}
313
314/// Estimates reading time in minutes from text content.
315///
316/// Uses 200 words-per-minute average, with a minimum of 1 minute.
317///
318/// # Examples
319///
320/// ```
321/// assert_eq!(ssg_core::reading_time("a short article"), 1);
322/// let long = "word ".repeat(600);
323/// assert_eq!(ssg_core::reading_time(&long), 3);
324/// ```
325#[must_use]
326pub fn reading_time(text: &str) -> usize {
327    (text.split_whitespace().count() / 200).max(1)
328}
329
330/// Converts a string to a URL-safe slug.
331///
332/// Lowercases ASCII letters, replaces non-alphanumeric runs with a
333/// single `-`, and trims leading/trailing separators.
334///
335/// # Examples
336///
337/// ```
338/// assert_eq!(ssg_core::slugify("Hello World!"), "hello-world");
339/// assert_eq!(ssg_core::slugify("Rust & Web"), "rust-web");
340/// assert_eq!(ssg_core::slugify("--leading--"), "leading");
341/// ```
342#[must_use]
343pub fn slugify(input: &str) -> String {
344    input
345        .to_lowercase()
346        .chars()
347        .map(|c| if c.is_alphanumeric() { c } else { '-' })
348        .collect::<String>()
349        .split('-')
350        .filter(|s| !s.is_empty())
351        .collect::<Vec<_>>()
352        .join("-")
353}
354
355#[cfg(test)]
356#[allow(clippy::unwrap_used)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn compile_markdown_basic() {
362        let html = compile_markdown("# Hello\n\nParagraph.");
363        assert!(html.contains("<h1>Hello</h1>"));
364        assert!(html.contains("<p>Paragraph.</p>"));
365    }
366
367    #[test]
368    fn compile_markdown_gfm_tables() {
369        let input = "| A | B |\n|---|---|\n| 1 | 2 |";
370        let html = compile_markdown(input);
371        assert!(html.contains("<table>"));
372    }
373
374    #[test]
375    fn compile_markdown_strikethrough() {
376        let html = compile_markdown("~~deleted~~");
377        assert!(html.contains("<del>deleted</del>"));
378    }
379
380    #[test]
381    fn parse_frontmatter_yaml() {
382        let (fm, body) = parse_frontmatter(
383            "---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
384        );
385        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
386        assert!(body.contains("# Body"));
387    }
388
389    #[test]
390    fn parse_frontmatter_toml() {
391        let (fm, body) =
392            parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
393        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
394        assert!(body.contains("# Body"));
395    }
396
397    #[test]
398    fn parse_frontmatter_json() {
399        let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
400        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
401        assert!(body.contains("# Body"));
402    }
403
404    #[test]
405    fn parse_frontmatter_none() {
406        let (fm, body) = parse_frontmatter("Just content");
407        assert!(fm.is_empty());
408        assert_eq!(body, "Just content");
409    }
410
411    #[test]
412    fn compile_page_full() {
413        let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
414        let (fm, html) = compile_page(input).unwrap();
415        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
416        assert!(html.contains("<h1>Hello</h1>"));
417    }
418
419    #[test]
420    fn strip_html_tags_basic() {
421        assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
422    }
423
424    #[test]
425    fn strip_html_tags_empty() {
426        assert_eq!(strip_html_tags(""), "");
427    }
428
429    #[test]
430    fn build_search_entry_strips_tags() {
431        let entry =
432            build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
433        assert_eq!(entry.title, "Title");
434        assert_eq!(entry.content, "Hello world");
435    }
436
437    #[test]
438    fn reading_time_short() {
439        assert_eq!(reading_time("one two three"), 1);
440    }
441
442    #[test]
443    fn reading_time_long() {
444        let text = "word ".repeat(600);
445        assert_eq!(reading_time(&text), 3);
446    }
447
448    #[test]
449    fn slugify_basic() {
450        assert_eq!(slugify("Hello World!"), "hello-world");
451        assert_eq!(slugify("Rust & Web"), "rust-web");
452    }
453
454    #[test]
455    fn error_display_frontmatter_parse_variant() {
456        let e = Error::FrontmatterParse {
457            syntax: "yaml mismatch".to_string(),
458        };
459        let s = format!("{e}");
460        assert!(s.contains("Frontmatter parse error"));
461        assert!(s.contains("yaml mismatch"));
462    }
463
464    #[test]
465    fn error_display_markdown_compile_variant() {
466        let e = Error::MarkdownCompile {
467            source: "broken markdown".to_string(),
468        };
469        let s = format!("{e}");
470        assert!(s.contains("Markdown compilation error"));
471        assert!(s.contains("broken markdown"));
472    }
473
474    #[test]
475    fn error_display_invalid_slug_variant() {
476        let e = Error::InvalidSlug {
477            input: "@@@".to_string(),
478        };
479        let s = format!("{e}");
480        assert!(s.contains("Invalid slug input"));
481        assert!(s.contains("@@@"));
482    }
483
484    #[test]
485    fn error_is_std_error_trait_object() {
486        // Smoke-tests the `impl std::error::Error for Error {}` block.
487        let e: Box<dyn std::error::Error> = Box::new(Error::InvalidSlug {
488            input: "x".to_string(),
489        });
490        assert!(!e.to_string().is_empty());
491        // No source by default.
492        assert!(std::error::Error::source(&*e).is_none());
493    }
494
495    #[test]
496    fn error_debug_impl_executes_for_each_variant() {
497        let e1 = Error::FrontmatterParse {
498            syntax: "a".to_string(),
499        };
500        let e2 = Error::MarkdownCompile {
501            source: "b".to_string(),
502        };
503        let e3 = Error::InvalidSlug {
504            input: "c".to_string(),
505        };
506        for e in [&e1, &e2, &e3] {
507            let s = format!("{e:?}");
508            assert!(!s.is_empty());
509        }
510    }
511
512    #[test]
513    fn search_entry_serialization_roundtrip() {
514        let e = SearchEntry {
515            title: "T".to_string(),
516            url: "/u".to_string(),
517            content: "C".to_string(),
518        };
519        let json = serde_json::to_string(&e).unwrap();
520        assert!(json.contains("\"title\":\"T\""));
521        let back: SearchEntry = serde_json::from_str(&json).unwrap();
522        assert_eq!(back.url, "/u");
523        assert_eq!(back.content, "C");
524        // Debug + Clone are derived; exercise them.
525        let _ = format!("{back:?}");
526        let _ = back.clone();
527    }
528
529    #[test]
530    fn compile_page_yields_empty_frontmatter_when_absent() {
531        let (fm, html) = compile_page("# Heading\n\nBody").unwrap();
532        assert!(fm.is_empty());
533        assert!(html.contains("<h1>Heading</h1>"));
534    }
535
536    #[test]
537    fn slugify_collapses_consecutive_separators() {
538        assert_eq!(slugify("foo!!!bar"), "foo-bar");
539        assert_eq!(slugify("--leading--"), "leading");
540    }
541
542    #[test]
543    fn slugify_empty_input_yields_empty() {
544        assert_eq!(slugify(""), "");
545        assert_eq!(slugify("???"), "");
546    }
547}