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/// Separators recognised when splitting a front-matter term list.
331///
332/// ASCII `,` plus the comma each writing system actually uses. A locale
333/// post written in Arabic separates its tags with `،` (U+060C) and one in
334/// Japanese with `、` (U+3001); splitting on ASCII alone collapses the whole
335/// list into a single term, which then slugifies into one enormous path
336/// component. Recognising the others costs nothing for ASCII input and makes
337/// a multilingual corpus behave the way its authors wrote it.
338const TERM_SEPARATORS: [char; 5] = [
339    ',',        // ASCII
340    '\u{060C}', // ، Arabic comma
341    '\u{FF0C}', // , fullwidth comma (CJK)
342    '\u{3001}', // 、 ideographic comma (CJK enumeration)
343    ';',        // occasionally used in hand-authored lists
344];
345
346/// Splits a front-matter term list into trimmed, non-empty terms.
347///
348/// Accepts every separator in [`TERM_SEPARATORS`], so a tag list keeps its
349/// terms whatever script it was written in.
350///
351/// # Examples
352///
353/// ```
354/// assert_eq!(ssg_core::split_terms("a, b,c"), vec!["a", "b", "c"]);
355/// // Arabic comma — one list of three, not one term.
356/// assert_eq!(ssg_core::split_terms("أ، ب، ج").len(), 3);
357/// assert!(ssg_core::split_terms(" , ,").is_empty());
358/// ```
359#[must_use]
360pub fn split_terms(input: &str) -> Vec<String> {
361    input
362        .split(TERM_SEPARATORS)
363        .map(str::trim)
364        .filter(|s| !s.is_empty())
365        .map(ToOwned::to_owned)
366        .collect()
367}
368
369/// Maximum slug length in **bytes**.
370///
371/// Slugs become path components, and the common Linux filesystems (ext4,
372/// btrfs, xfs) cap a single component at 255 *bytes*. `is_alphanumeric` is
373/// Unicode-aware, so non-Latin scripts survive slugification at 2-4 bytes per
374/// character: a 230-character Arabic term is 348 bytes and the build dies with
375/// ENAMETOOLONG. macOS (APFS) counts *characters*, so the same input succeeds
376/// there — which is how this reaches CI without any contributor seeing it.
377///
378/// 200 leaves headroom for the extensions and suffixes callers append.
379const MAX_SLUG_BYTES: usize = 200;
380
381/// Converts a string to a URL-safe slug.
382///
383/// Lowercases ASCII letters, replaces non-alphanumeric runs with a
384/// single `-`, and trims leading/trailing separators. The result is
385/// truncated to [`MAX_SLUG_BYTES`] bytes on a character boundary, so a
386/// slug is always a legal path component on Linux filesystems.
387///
388/// # Examples
389///
390/// ```
391/// assert_eq!(ssg_core::slugify("Hello World!"), "hello-world");
392/// assert_eq!(ssg_core::slugify("Rust & Web"), "rust-web");
393/// assert_eq!(ssg_core::slugify("--leading--"), "leading");
394/// // Long terms are capped in bytes, not characters.
395/// assert!(ssg_core::slugify(&"ا".repeat(400)).len() <= 200);
396/// ```
397#[must_use]
398pub fn slugify(input: &str) -> String {
399    let slug = input
400        .to_lowercase()
401        .chars()
402        .map(|c| if c.is_alphanumeric() { c } else { '-' })
403        .collect::<String>()
404        .split('-')
405        .filter(|s| !s.is_empty())
406        .collect::<Vec<_>>()
407        .join("-");
408
409    if slug.len() <= MAX_SLUG_BYTES {
410        return slug;
411    }
412
413    // Truncate on a char boundary — byte-slicing a multi-byte sequence
414    // panics — then trim any separator the cut leaves dangling.
415    let mut end = MAX_SLUG_BYTES;
416    while end > 0 && !slug.is_char_boundary(end) {
417        end -= 1;
418    }
419    slug[..end].trim_end_matches('-').to_owned()
420}
421
422#[cfg(test)]
423#[allow(clippy::unwrap_used)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn slugify_caps_length_in_bytes_not_characters() {
429        // The regression: a real Arabic tag list collapsed into one term is
430        // 230 characters but 348 UTF-8 bytes. ext4 caps a path component at
431        // 255 bytes, so the build died with ENAMETOOLONG; APFS counts
432        // characters, so macOS never saw it.
433        let arabic = "\u{0622}\u{0641}\u{0627}\u{0642} ".repeat(60);
434        let slug = slugify(&arabic);
435        assert!(
436            slug.len() <= MAX_SLUG_BYTES,
437            "slug is {} bytes, over the {MAX_SLUG_BYTES}-byte cap",
438            slug.len()
439        );
440        // Still a usable slug, not an empty string.
441        assert!(!slug.is_empty());
442        assert!(!slug.ends_with('-'), "cut left a dangling separator");
443    }
444
445    #[test]
446    fn slugify_truncates_on_a_char_boundary() {
447        // Byte-slicing a multi-byte sequence panics; the cut must land on a
448        // boundary for every offset a long multi-byte input can produce.
449        for n in 90..140 {
450            let slug = slugify(&"\u{3042}".repeat(n)); // hiragana A, 3 bytes
451            assert!(slug.len() <= MAX_SLUG_BYTES);
452            assert!(std::str::from_utf8(slug.as_bytes()).is_ok());
453        }
454    }
455
456    #[test]
457    fn slugify_leaves_short_slugs_untouched() {
458        assert_eq!(slugify("Hello World!"), "hello-world");
459        assert_eq!(slugify("Rust & Web"), "rust-web");
460    }
461
462    #[test]
463    fn split_terms_handles_non_ascii_separators() {
464        // Each script's own comma. Splitting on ASCII alone yields one term.
465        assert_eq!(split_terms("a, b, c").len(), 3);
466        assert_eq!(
467            split_terms("\u{0623}\u{060C} \u{0628}\u{060C} \u{062C}").len(),
468            3
469        );
470        assert_eq!(
471            split_terms("\u{3042}\u{3001}\u{3044}\u{3001}\u{3046}").len(),
472            3
473        );
474        assert_eq!(split_terms("\u{7532}\u{FF0C}\u{4E59}").len(), 2);
475        assert_eq!(split_terms("a; b").len(), 2);
476    }
477
478    #[test]
479    fn split_terms_trims_and_drops_empties() {
480        assert_eq!(split_terms("  a  ,,  b  ,"), vec!["a", "b"]);
481        assert!(split_terms(" , , ").is_empty());
482        assert!(split_terms("").is_empty());
483    }
484
485    #[test]
486    fn split_terms_then_slugify_stays_within_the_byte_cap() {
487        // The two fixes together: the real corpus shape. Each term is short,
488        // so nothing is truncated and no path component can overflow.
489        let list = "\u{0623}\u{0644}\u{0623}\u{0639}\u{0645}\u{0627}\u{0644}\u{060C} \u{0627}\u{0644}\u{062A}\u{062C}\u{0627}\u{0631}\u{0629}\u{060C} DORA";
490        let slugs: Vec<String> =
491            split_terms(list).iter().map(|t| slugify(t)).collect();
492        assert_eq!(slugs.len(), 3);
493        for s in &slugs {
494            assert!(s.len() <= MAX_SLUG_BYTES);
495            assert!(!s.is_empty());
496        }
497    }
498
499    #[test]
500    fn compile_markdown_basic() {
501        let html = compile_markdown("# Hello\n\nParagraph.");
502        assert!(html.contains("<h1>Hello</h1>"));
503        assert!(html.contains("<p>Paragraph.</p>"));
504    }
505
506    #[test]
507    fn compile_markdown_gfm_tables() {
508        let input = "| A | B |\n|---|---|\n| 1 | 2 |";
509        let html = compile_markdown(input);
510        assert!(html.contains("<table>"));
511    }
512
513    #[test]
514    fn compile_markdown_strikethrough() {
515        let html = compile_markdown("~~deleted~~");
516        assert!(html.contains("<del>deleted</del>"));
517    }
518
519    #[test]
520    fn parse_frontmatter_yaml() {
521        let (fm, body) = parse_frontmatter(
522            "---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
523        );
524        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
525        assert!(body.contains("# Body"));
526    }
527
528    #[test]
529    fn parse_frontmatter_toml() {
530        let (fm, body) =
531            parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
532        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
533        assert!(body.contains("# Body"));
534    }
535
536    #[test]
537    fn parse_frontmatter_json() {
538        let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
539        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
540        assert!(body.contains("# Body"));
541    }
542
543    #[test]
544    fn parse_frontmatter_none() {
545        let (fm, body) = parse_frontmatter("Just content");
546        assert!(fm.is_empty());
547        assert_eq!(body, "Just content");
548    }
549
550    #[test]
551    fn compile_page_full() {
552        let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
553        let (fm, html) = compile_page(input).unwrap();
554        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
555        assert!(html.contains("<h1>Hello</h1>"));
556    }
557
558    #[test]
559    fn strip_html_tags_basic() {
560        assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
561    }
562
563    #[test]
564    fn strip_html_tags_empty() {
565        assert_eq!(strip_html_tags(""), "");
566    }
567
568    #[test]
569    fn build_search_entry_strips_tags() {
570        let entry =
571            build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
572        assert_eq!(entry.title, "Title");
573        assert_eq!(entry.content, "Hello world");
574    }
575
576    #[test]
577    fn reading_time_short() {
578        assert_eq!(reading_time("one two three"), 1);
579    }
580
581    #[test]
582    fn reading_time_long() {
583        let text = "word ".repeat(600);
584        assert_eq!(reading_time(&text), 3);
585    }
586
587    #[test]
588    fn slugify_basic() {
589        assert_eq!(slugify("Hello World!"), "hello-world");
590        assert_eq!(slugify("Rust & Web"), "rust-web");
591    }
592
593    #[test]
594    fn error_display_frontmatter_parse_variant() {
595        let e = Error::FrontmatterParse {
596            syntax: "yaml mismatch".to_string(),
597        };
598        let s = format!("{e}");
599        assert!(s.contains("Frontmatter parse error"));
600        assert!(s.contains("yaml mismatch"));
601    }
602
603    #[test]
604    fn error_display_markdown_compile_variant() {
605        let e = Error::MarkdownCompile {
606            source: "broken markdown".to_string(),
607        };
608        let s = format!("{e}");
609        assert!(s.contains("Markdown compilation error"));
610        assert!(s.contains("broken markdown"));
611    }
612
613    #[test]
614    fn error_display_invalid_slug_variant() {
615        let e = Error::InvalidSlug {
616            input: "@@@".to_string(),
617        };
618        let s = format!("{e}");
619        assert!(s.contains("Invalid slug input"));
620        assert!(s.contains("@@@"));
621    }
622
623    #[test]
624    fn error_is_std_error_trait_object() {
625        // Smoke-tests the `impl std::error::Error for Error {}` block.
626        let e: Box<dyn std::error::Error> = Box::new(Error::InvalidSlug {
627            input: "x".to_string(),
628        });
629        assert!(!e.to_string().is_empty());
630        // No source by default.
631        assert!(std::error::Error::source(&*e).is_none());
632    }
633
634    #[test]
635    fn error_debug_impl_executes_for_each_variant() {
636        let e1 = Error::FrontmatterParse {
637            syntax: "a".to_string(),
638        };
639        let e2 = Error::MarkdownCompile {
640            source: "b".to_string(),
641        };
642        let e3 = Error::InvalidSlug {
643            input: "c".to_string(),
644        };
645        for e in [&e1, &e2, &e3] {
646            let s = format!("{e:?}");
647            assert!(!s.is_empty());
648        }
649    }
650
651    #[test]
652    fn search_entry_serialization_roundtrip() {
653        let e = SearchEntry {
654            title: "T".to_string(),
655            url: "/u".to_string(),
656            content: "C".to_string(),
657        };
658        let json = serde_json::to_string(&e).unwrap();
659        assert!(json.contains("\"title\":\"T\""));
660        let back: SearchEntry = serde_json::from_str(&json).unwrap();
661        assert_eq!(back.url, "/u");
662        assert_eq!(back.content, "C");
663        // Debug + Clone are derived; exercise them.
664        let _ = format!("{back:?}");
665        let _ = back.clone();
666    }
667
668    #[test]
669    fn compile_page_yields_empty_frontmatter_when_absent() {
670        let (fm, html) = compile_page("# Heading\n\nBody").unwrap();
671        assert!(fm.is_empty());
672        assert!(html.contains("<h1>Heading</h1>"));
673    }
674
675    #[test]
676    fn slugify_collapses_consecutive_separators() {
677        assert_eq!(slugify("foo!!!bar"), "foo-bar");
678        assert_eq!(slugify("--leading--"), "leading");
679    }
680
681    #[test]
682    fn slugify_empty_input_yields_empty() {
683        assert_eq!(slugify(""), "");
684        assert_eq!(slugify("???"), "");
685    }
686}