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 (`openssl`, `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
20use std::collections::HashMap;
21use std::fmt;
22
23/// The error type for ssg-core operations.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Error {
26    /// TOML/YAML/JSON parsing failures.
27    FrontmatterParse {
28        /// The syntax format (e.g. "toml", "yaml", "json") or parse error detail.
29        syntax: String,
30    },
31    /// Markdown rendering bugs.
32    MarkdownCompile {
33        /// Detail about what failed.
34        source: String,
35    },
36    /// Slugification layout validation failures.
37    InvalidSlug {
38        /// The invalid input string.
39        input: String,
40    },
41}
42
43impl fmt::Display for Error {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::FrontmatterParse { syntax } => {
47                write!(f, "Frontmatter parse error: {syntax}")
48            }
49            Self::MarkdownCompile { source } => {
50                write!(f, "Markdown compilation error: {source}")
51            }
52            Self::InvalidSlug { input } => {
53                write!(f, "Invalid slug input: {input}")
54            }
55        }
56    }
57}
58
59impl std::error::Error for Error {}
60
61/// Specialized Result type for ssg-core operations.
62pub type Result<T> = std::result::Result<T, Error>;
63
64/// Compile a Markdown string to HTML.
65///
66/// Supports GitHub Flavored Markdown: tables, strikethrough, task lists.
67///
68/// # Example
69///
70/// ```
71/// let html = ssg_core::compile_markdown("# Hello\n\nWorld");
72/// assert!(html.contains("<h1>Hello</h1>"));
73/// assert!(html.contains("<p>World</p>"));
74/// ```
75#[must_use]
76pub fn compile_markdown(input: &str) -> String {
77    use pulldown_cmark::{html, Options, Parser};
78
79    let options = Options::ENABLE_TABLES
80        | Options::ENABLE_STRIKETHROUGH
81        | Options::ENABLE_TASKLISTS;
82
83    let parser = Parser::new_ext(input, options);
84    let mut html_output = String::with_capacity(input.len() * 2);
85    html::push_html(&mut html_output, parser);
86    html_output
87}
88
89/// Parse frontmatter from a Markdown file.
90///
91/// Supports TOML (`+++`), YAML (`---`), and JSON (`{`) delimiters.
92/// Returns `(frontmatter_map, body_without_frontmatter)`.
93///
94/// # Example
95///
96/// ```
97/// let input = "---\ntitle: Hello\n---\n# Body";
98/// let (fm, body) = ssg_core::parse_frontmatter(input);
99/// assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
100/// assert!(body.contains("# Body"));
101/// ```
102pub fn parse_frontmatter(
103    input: &str,
104) -> (HashMap<String, serde_json::Value>, String) {
105    let trimmed = input.trim_start();
106
107    // TOML frontmatter: +++...+++
108    if let Some(after) = trimmed.strip_prefix("+++") {
109        if let Some(end) = after.find("+++") {
110            let fm_str = &after[..end];
111            let body = &after[end + 3..];
112            if let Ok(value) = toml::from_str::<serde_json::Value>(fm_str) {
113                if let Some(map) = value.as_object() {
114                    return (
115                        map.iter()
116                            .map(|(k, v)| (k.clone(), v.clone()))
117                            .collect(),
118                        body.to_string(),
119                    );
120                }
121            }
122            return (HashMap::new(), body.to_string());
123        }
124    }
125
126    // YAML frontmatter: ---...---
127    if let Some(after) = trimmed.strip_prefix("---") {
128        if let Some(end) = after.find("---") {
129            let fm_str = &after[..end].trim();
130            let body = &after[end + 3..];
131            // Simple key: value parser for common YAML frontmatter
132            let mut map = HashMap::new();
133            for line in fm_str.lines() {
134                if let Some((key, val)) = line.split_once(':') {
135                    let key = key.trim().to_string();
136                    let val = val.trim().to_string();
137                    let _ = map.insert(key, serde_json::Value::String(val));
138                }
139            }
140            return (map, body.to_string());
141        }
142    }
143
144    // JSON frontmatter: {...}
145    if trimmed.starts_with('{') {
146        // Find matching closing brace
147        let mut depth = 0;
148        let mut end = None;
149        for (i, c) in trimmed.char_indices() {
150            match c {
151                '{' => depth += 1,
152                '}' => {
153                    depth -= 1;
154                    if depth == 0 {
155                        end = Some(i + 1);
156                        break;
157                    }
158                }
159                _ => {}
160            }
161        }
162        if let Some(end_pos) = end {
163            let fm_str = &trimmed[..end_pos];
164            let body = &trimmed[end_pos..];
165            if let Ok(map) = serde_json::from_str::<
166                HashMap<String, serde_json::Value>,
167            >(fm_str)
168            {
169                return (map, body.to_string());
170            }
171        }
172    }
173
174    (HashMap::new(), input.to_string())
175}
176
177/// Compile a complete page: parse frontmatter, render Markdown to HTML.
178///
179/// Returns `(frontmatter, html_body)`.
180pub fn compile_page(
181    input: &str,
182) -> Result<(HashMap<String, serde_json::Value>, String)> {
183    let (frontmatter, body) = parse_frontmatter(input);
184    let html = compile_markdown(&body);
185    Ok((frontmatter, html))
186}
187
188/// Generate a search index entry from HTML content.
189#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
190pub struct SearchEntry {
191    /// Page title.
192    pub title: String,
193    /// Page URL.
194    pub url: String,
195    /// Plain text content for search matching.
196    pub content: String,
197}
198
199/// Strip HTML tags from a string (simple implementation).
200#[must_use]
201pub fn strip_html_tags(html: &str) -> String {
202    let mut result = String::with_capacity(html.len());
203    let mut in_tag = false;
204
205    for c in html.chars() {
206        match c {
207            '<' => in_tag = true,
208            '>' => in_tag = false,
209            _ if !in_tag => result.push(c),
210            _ => {}
211        }
212    }
213
214    result
215}
216
217/// Build a search index entry from HTML content and metadata.
218#[must_use]
219pub fn build_search_entry(title: &str, url: &str, html: &str) -> SearchEntry {
220    let content = strip_html_tags(html);
221    // Collapse whitespace for compact index
222    let content: String =
223        content.split_whitespace().collect::<Vec<_>>().join(" ");
224    SearchEntry {
225        title: title.to_string(),
226        url: url.to_string(),
227        content,
228    }
229}
230
231/// Estimates reading time in minutes from text content.
232///
233/// Uses 200 words-per-minute average, with a minimum of 1 minute.
234#[must_use]
235pub fn reading_time(text: &str) -> usize {
236    (text.split_whitespace().count() / 200).max(1)
237}
238
239/// Converts a string to a URL-safe slug.
240#[must_use]
241pub fn slugify(input: &str) -> String {
242    input
243        .to_lowercase()
244        .chars()
245        .map(|c| if c.is_alphanumeric() { c } else { '-' })
246        .collect::<String>()
247        .split('-')
248        .filter(|s| !s.is_empty())
249        .collect::<Vec<_>>()
250        .join("-")
251}
252
253#[cfg(test)]
254#[allow(clippy::unwrap_used)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn compile_markdown_basic() {
260        let html = compile_markdown("# Hello\n\nParagraph.");
261        assert!(html.contains("<h1>Hello</h1>"));
262        assert!(html.contains("<p>Paragraph.</p>"));
263    }
264
265    #[test]
266    fn compile_markdown_gfm_tables() {
267        let input = "| A | B |\n|---|---|\n| 1 | 2 |";
268        let html = compile_markdown(input);
269        assert!(html.contains("<table>"));
270    }
271
272    #[test]
273    fn compile_markdown_strikethrough() {
274        let html = compile_markdown("~~deleted~~");
275        assert!(html.contains("<del>deleted</del>"));
276    }
277
278    #[test]
279    fn parse_frontmatter_yaml() {
280        let (fm, body) = parse_frontmatter(
281            "---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
282        );
283        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
284        assert!(body.contains("# Body"));
285    }
286
287    #[test]
288    fn parse_frontmatter_toml() {
289        let (fm, body) =
290            parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
291        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
292        assert!(body.contains("# Body"));
293    }
294
295    #[test]
296    fn parse_frontmatter_json() {
297        let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
298        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
299        assert!(body.contains("# Body"));
300    }
301
302    #[test]
303    fn parse_frontmatter_none() {
304        let (fm, body) = parse_frontmatter("Just content");
305        assert!(fm.is_empty());
306        assert_eq!(body, "Just content");
307    }
308
309    #[test]
310    fn compile_page_full() {
311        let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
312        let (fm, html) = compile_page(input).unwrap();
313        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
314        assert!(html.contains("<h1>Hello</h1>"));
315    }
316
317    #[test]
318    fn strip_html_tags_basic() {
319        assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
320    }
321
322    #[test]
323    fn strip_html_tags_empty() {
324        assert_eq!(strip_html_tags(""), "");
325    }
326
327    #[test]
328    fn build_search_entry_strips_tags() {
329        let entry =
330            build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
331        assert_eq!(entry.title, "Title");
332        assert_eq!(entry.content, "Hello world");
333    }
334
335    #[test]
336    fn reading_time_short() {
337        assert_eq!(reading_time("one two three"), 1);
338    }
339
340    #[test]
341    fn reading_time_long() {
342        let text = "word ".repeat(600);
343        assert_eq!(reading_time(&text), 3);
344    }
345
346    #[test]
347    fn slugify_basic() {
348        assert_eq!(slugify("Hello World!"), "hello-world");
349        assert_eq!(slugify("Rust & Web"), "rust-web");
350    }
351}