Skip to main content

ssg_core/
lib.rs

1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
2#![forbid(unsafe_code)]
3// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
4// SPDX-License-Identifier: Apache-2.0 OR MIT
5
6//! # ssg-core — Platform-independent SSG compilation pipeline
7//!
8//! This crate contains the pure-logic core of SSG, with no system
9//! dependencies (`rayon`, `http-handle`). It compiles to
10//! `wasm32-wasi` and `wasm32-unknown-unknown` (via `wasm-bindgen`).
11//!
12//! ## Features
13//!
14//! - Markdown → HTML compilation (pulldown-cmark with GFM extensions)
15//! - Frontmatter parsing (TOML/JSON/YAML)
16//! - Template rendering (when `minijinja` is enabled)
17//! - Shortcode expansion
18//! - SEO metadata generation
19//! - Search index generation
20
21pub mod content_provider;
22pub mod isr_manifest;
23
24pub use content_provider::{
25    ContentProvider, FsContentProvider, MemoryContentProvider, ProviderError,
26    ProviderResult,
27};
28pub use isr_manifest::{
29    build_entry, hash_sources, CachePolicy, Manifest, ManifestEntry,
30    DEFAULT_SWR, DEFAULT_S_MAXAGE, MANIFEST_VERSION,
31};
32
33use std::collections::HashMap;
34use std::fmt;
35
36/// The error type for ssg-core operations.
37///
38/// # Examples
39///
40/// ```
41/// use ssg_core::Error;
42///
43/// let err = Error::InvalidSlug { input: "@@@".to_string() };
44/// assert!(err.to_string().contains("Invalid slug input"));
45/// ```
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum Error {
48    /// TOML/YAML/JSON parsing failures.
49    FrontmatterParse {
50        /// The syntax format (e.g. "toml", "yaml", "json") or parse error detail.
51        syntax: String,
52    },
53    /// Markdown rendering bugs.
54    MarkdownCompile {
55        /// Detail about what failed.
56        source: String,
57    },
58    /// Slugification layout validation failures.
59    InvalidSlug {
60        /// The invalid input string.
61        input: String,
62    },
63}
64
65impl fmt::Display for Error {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Self::FrontmatterParse { syntax } => {
69                write!(f, "Frontmatter parse error: {syntax}")
70            }
71            Self::MarkdownCompile { source } => {
72                write!(f, "Markdown compilation error: {source}")
73            }
74            Self::InvalidSlug { input } => {
75                write!(f, "Invalid slug input: {input}")
76            }
77        }
78    }
79}
80
81impl std::error::Error for Error {}
82
83/// Specialized Result type for ssg-core operations.
84pub type Result<T> = std::result::Result<T, Error>;
85
86/// Compile a Markdown string to HTML.
87///
88/// Supports GitHub Flavored Markdown: tables, strikethrough, task lists.
89///
90/// # Example
91///
92/// ```
93/// let html = ssg_core::compile_markdown("# Hello\n\nWorld");
94/// assert!(html.contains("<h1>Hello</h1>"));
95/// assert!(html.contains("<p>World</p>"));
96/// ```
97#[must_use]
98pub fn compile_markdown(input: &str) -> String {
99    use pulldown_cmark::{html, Options, Parser};
100
101    let options = Options::ENABLE_TABLES
102        | Options::ENABLE_STRIKETHROUGH
103        | Options::ENABLE_TASKLISTS;
104
105    let parser = Parser::new_ext(input, options);
106    let mut html_output = String::with_capacity(input.len() * 2);
107    html::push_html(&mut html_output, parser);
108    html_output
109}
110
111/// Parse frontmatter from a Markdown file.
112///
113/// Supports TOML (`+++`), YAML (`---`), and JSON (`{`) delimiters.
114/// Returns `(frontmatter_map, body_without_frontmatter)`.
115///
116/// # Example
117///
118/// ```
119/// let input = "---\ntitle: Hello\n---\n# Body";
120/// let (fm, body) = ssg_core::parse_frontmatter(input);
121/// assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
122/// assert!(body.contains("# Body"));
123/// ```
124pub fn parse_frontmatter(
125    input: &str,
126) -> (HashMap<String, serde_json::Value>, String) {
127    // Zero-copy core (issue #578, plan §4 3.1): the body is sliced out
128    // of `input` exactly once and materialised exactly once here — no
129    // per-branch `to_string()` and no metadata-map clone rebuilds.
130    let (map, body) = parse_frontmatter_borrowed(input);
131    (map, body.to_string())
132}
133
134/// Borrowed-body core of [`parse_frontmatter`].
135///
136/// Returns the metadata map by *moving* parsed entries (never cloning
137/// them) and the body as a slice of `input`, leaving the single owned
138/// materialisation to the public wrapper (issue #578, plan §4 3.1).
139fn parse_frontmatter_borrowed(
140    input: &str,
141) -> (HashMap<String, serde_json::Value>, &str) {
142    let trimmed = input.trim_start();
143
144    // TOML frontmatter: +++...+++
145    if let Some(after) = trimmed.strip_prefix("+++") {
146        if let Some(end) = after.find("+++") {
147            let fm_str = &after[..end];
148            let body = &after[end + 3..];
149            if let Ok(serde_json::Value::Object(map)) =
150                toml::from_str::<serde_json::Value>(fm_str)
151            {
152                // Move the parsed entries into the final map — the
153                // previous `(k.clone(), v.clone())` rebuild is gone.
154                return (map.into_iter().collect(), body);
155            }
156            return (HashMap::new(), body);
157        }
158    }
159
160    // YAML frontmatter: ---...---
161    if let Some(after) = trimmed.strip_prefix("---") {
162        if let Some(end) = after.find("---") {
163            let fm_str = &after[..end];
164            let body = &after[end + 3..];
165            match noyalib::from_str::<serde_json::Value>(fm_str) {
166                Ok(serde_json::Value::Object(map)) => {
167                    return (map.into_iter().collect(), body);
168                }
169                Ok(_) => {
170                    // Top-level non-mapping (e.g. a bare list or scalar)
171                    // — preserve the body but emit no globals.
172                    return (HashMap::new(), body);
173                }
174                Err(e) => {
175                    log::warn!("YAML frontmatter parse error: {e}");
176                    return (HashMap::new(), body);
177                }
178            }
179        }
180    }
181
182    // JSON frontmatter: {...}
183    if trimmed.starts_with('{') {
184        // Find matching closing brace
185        let mut depth = 0;
186        let mut end = None;
187        for (i, c) in trimmed.char_indices() {
188            match c {
189                '{' => depth += 1,
190                '}' => {
191                    depth -= 1;
192                    if depth == 0 {
193                        end = Some(i + 1);
194                        break;
195                    }
196                }
197                _ => {}
198            }
199        }
200        if let Some(end_pos) = end {
201            let fm_str = &trimmed[..end_pos];
202            let body = &trimmed[end_pos..];
203            if let Ok(map) = serde_json::from_str::<
204                HashMap<String, serde_json::Value>,
205            >(fm_str)
206            {
207                return (map, body);
208            }
209        }
210    }
211
212    (HashMap::new(), input)
213}
214
215/// Compile a complete page: parse frontmatter, render Markdown to HTML.
216///
217/// Returns `(frontmatter, html_body)`.
218///
219/// # Errors
220/// Currently infallible — returns `Ok` for every input. The `Result`
221/// signature is preserved so that future stricter validation can
222/// surface failures without a breaking API change.
223///
224/// # Examples
225///
226/// ```
227/// let input = "---\ntitle: Test\n---\n# Heading";
228/// let (fm, html) = ssg_core::compile_page(input).unwrap();
229/// assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
230/// assert!(html.contains("<h1>Heading</h1>"));
231/// ```
232pub fn compile_page(
233    input: &str,
234) -> Result<(HashMap<String, serde_json::Value>, String)> {
235    let (frontmatter, body) = parse_frontmatter(input);
236    let html = compile_markdown(&body);
237    Ok((frontmatter, html))
238}
239
240/// Generate a search index entry from HTML content.
241///
242/// # Examples
243///
244/// ```
245/// let entry = ssg_core::SearchEntry {
246///     title: "Hi".to_string(),
247///     url: "/".to_string(),
248///     content: "hello".to_string(),
249/// };
250/// let json = serde_json::to_string(&entry).unwrap();
251/// assert!(json.contains("\"title\":\"Hi\""));
252/// ```
253#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
254pub struct SearchEntry {
255    /// Page title.
256    pub title: String,
257    /// Page URL.
258    pub url: String,
259    /// Plain text content for search matching.
260    pub content: String,
261}
262
263/// Strip HTML tags from a string (simple implementation).
264///
265/// # Examples
266///
267/// ```
268/// let plain = ssg_core::strip_html_tags("<p>Hello <b>world</b></p>");
269/// assert_eq!(plain, "Hello world");
270/// ```
271#[must_use]
272pub fn strip_html_tags(html: &str) -> String {
273    let mut result = String::with_capacity(html.len());
274    let mut in_tag = false;
275
276    for c in html.chars() {
277        match c {
278            '<' => in_tag = true,
279            '>' => in_tag = false,
280            _ if !in_tag => result.push(c),
281            _ => {}
282        }
283    }
284
285    result
286}
287
288/// Build a search index entry from HTML content and metadata.
289///
290/// # Examples
291///
292/// ```
293/// let entry = ssg_core::build_search_entry(
294///     "Welcome",
295///     "/index.html",
296///     "<p>Hello <b>world</b></p>",
297/// );
298/// assert_eq!(entry.title, "Welcome");
299/// assert_eq!(entry.url, "/index.html");
300/// assert_eq!(entry.content, "Hello world");
301/// ```
302#[must_use]
303pub fn build_search_entry(title: &str, url: &str, html: &str) -> SearchEntry {
304    let content = strip_html_tags(html);
305    // Collapse whitespace for compact index
306    let content: String =
307        content.split_whitespace().collect::<Vec<_>>().join(" ");
308    SearchEntry {
309        title: title.to_string(),
310        url: url.to_string(),
311        content,
312    }
313}
314
315/// Estimates reading time in minutes from text content.
316///
317/// Uses 200 words-per-minute average, with a minimum of 1 minute.
318///
319/// # Examples
320///
321/// ```
322/// assert_eq!(ssg_core::reading_time("a short article"), 1);
323/// let long = "word ".repeat(600);
324/// assert_eq!(ssg_core::reading_time(&long), 3);
325/// ```
326#[must_use]
327pub fn reading_time(text: &str) -> usize {
328    (text.split_whitespace().count() / 200).max(1)
329}
330
331/// Separators recognised when splitting a front-matter term list.
332///
333/// ASCII `,` plus the comma each writing system actually uses. A locale
334/// post written in Arabic separates its tags with `،` (U+060C) and one in
335/// Japanese with `、` (U+3001); splitting on ASCII alone collapses the whole
336/// list into a single term, which then slugifies into one enormous path
337/// component. Recognising the others costs nothing for ASCII input and makes
338/// a multilingual corpus behave the way its authors wrote it.
339const TERM_SEPARATORS: [char; 5] = [
340    ',',        // ASCII
341    '\u{060C}', // ، Arabic comma
342    '\u{FF0C}', // , fullwidth comma (CJK)
343    '\u{3001}', // 、 ideographic comma (CJK enumeration)
344    ';',        // occasionally used in hand-authored lists
345];
346
347/// Splits a front-matter term list into trimmed, non-empty terms.
348///
349/// Accepts a comma or semicolon, plus the Arabic comma (`،`), fullwidth
350/// comma (`,`) and ideographic comma (`、`), so a tag list keeps its terms
351/// whatever script it was written in.
352///
353/// # Examples
354///
355/// ```
356/// assert_eq!(ssg_core::split_terms("a, b,c"), vec!["a", "b", "c"]);
357/// // Arabic comma — one list of three, not one term.
358/// assert_eq!(ssg_core::split_terms("أ، ب، ج").len(), 3);
359/// assert!(ssg_core::split_terms(" , ,").is_empty());
360/// ```
361#[must_use]
362pub fn split_terms(input: &str) -> Vec<String> {
363    input
364        .split(TERM_SEPARATORS)
365        .map(str::trim)
366        .filter(|s| !s.is_empty())
367        .map(ToOwned::to_owned)
368        .collect()
369}
370
371/// Maximum slug length in **bytes**.
372///
373/// Slugs become path components, and the common Linux filesystems (ext4,
374/// btrfs, xfs) cap a single component at 255 *bytes*. `is_alphanumeric` is
375/// Unicode-aware, so non-Latin scripts survive slugification at 2-4 bytes per
376/// character: a 230-character Arabic term is 348 bytes and the build dies with
377/// ENAMETOOLONG. macOS (APFS) counts *characters*, so the same input succeeds
378/// there — which is how this reaches CI without any contributor seeing it.
379///
380/// 200 leaves headroom for the extensions and suffixes callers append.
381const MAX_SLUG_BYTES: usize = 200;
382
383/// Converts a string to a URL-safe slug.
384///
385/// Lowercases ASCII letters, replaces non-alphanumeric runs with a
386/// single `-`, and trims leading/trailing separators. The result is
387/// truncated to 200 bytes on a character boundary, so a slug is always a
388/// legal path component on Linux filesystems.
389///
390/// # Examples
391///
392/// ```
393/// assert_eq!(ssg_core::slugify("Hello World!"), "hello-world");
394/// assert_eq!(ssg_core::slugify("Rust & Web"), "rust-web");
395/// assert_eq!(ssg_core::slugify("--leading--"), "leading");
396/// // Long terms are capped in bytes, not characters.
397/// assert!(ssg_core::slugify(&"ا".repeat(400)).len() <= 200);
398/// ```
399#[must_use]
400pub fn slugify(input: &str) -> String {
401    let slug = input
402        .to_lowercase()
403        .chars()
404        .map(|c| if c.is_alphanumeric() { c } else { '-' })
405        .collect::<String>()
406        .split('-')
407        .filter(|s| !s.is_empty())
408        .collect::<Vec<_>>()
409        .join("-");
410
411    if slug.len() <= MAX_SLUG_BYTES {
412        return slug;
413    }
414
415    // Truncate on a char boundary — byte-slicing a multi-byte sequence
416    // panics — then trim any separator the cut leaves dangling.
417    let mut end = MAX_SLUG_BYTES;
418    while end > 0 && !slug.is_char_boundary(end) {
419        end -= 1;
420    }
421    slug[..end].trim_end_matches('-').to_owned()
422}
423
424#[cfg(test)]
425mod tests {
426
427    /// A code span containing HTML must be escaped, not emitted as markup.
428    ///
429    /// The blog example carries an accessibility checklist whose second item
430    /// is "Every `<img>` has a meaningful `alt`". Rendered through the legacy
431    /// `staticdatagen` compiler that sentence produces a real, attribute-less
432    /// `<img>` element — so a page about alt text ships an image without one,
433    /// and `tests/example_outputs.rs` fails on it.
434    ///
435    /// `compile_markdown` is the replacement path (WS1 of the v0.0.58 plan
436    /// retires `staticdatagen`). This pins the correct behaviour so the fix
437    /// arrives with the migration and cannot regress afterwards.
438    #[test]
439    fn code_spans_escape_html_tags() {
440        let html = compile_markdown(
441            "Every `<img>` has a meaningful `alt` (or `alt=\"\"`).",
442        );
443        assert!(
444            html.contains("<code>&lt;img&gt;</code>"),
445            "code span was not escaped: {html}"
446        );
447        assert!(
448            !html.contains("<code><img></code>"),
449            "code span emitted a real <img> element: {html}"
450        );
451    }
452
453    #[test]
454    fn fenced_blocks_escape_html_tags() {
455        let html = compile_markdown("```\n<script>alert(1)</script>\n```\n");
456        assert!(
457            html.contains("&lt;script&gt;"),
458            "fenced block was not escaped: {html}"
459        );
460        assert!(
461            !html.contains("<script>alert(1)</script>"),
462            "fenced block emitted executable markup: {html}"
463        );
464    }
465
466    use super::*;
467
468    #[test]
469    fn slugify_caps_length_in_bytes_not_characters() {
470        // The regression: a real Arabic tag list collapsed into one term is
471        // 230 characters but 348 UTF-8 bytes. ext4 caps a path component at
472        // 255 bytes, so the build died with ENAMETOOLONG; APFS counts
473        // characters, so macOS never saw it.
474        let arabic = "\u{0622}\u{0641}\u{0627}\u{0642} ".repeat(60);
475        let slug = slugify(&arabic);
476        assert!(
477            slug.len() <= MAX_SLUG_BYTES,
478            "slug is {} bytes, over the {MAX_SLUG_BYTES}-byte cap",
479            slug.len()
480        );
481        // Still a usable slug, not an empty string.
482        assert!(!slug.is_empty());
483        assert!(!slug.ends_with('-'), "cut left a dangling separator");
484    }
485
486    #[test]
487    fn slugify_truncates_on_a_char_boundary() {
488        // Byte-slicing a multi-byte sequence panics; the cut must land on a
489        // boundary for every offset a long multi-byte input can produce.
490        for n in 90..140 {
491            let slug = slugify(&"\u{3042}".repeat(n)); // hiragana A, 3 bytes
492            assert!(slug.len() <= MAX_SLUG_BYTES);
493            assert!(std::str::from_utf8(slug.as_bytes()).is_ok());
494        }
495    }
496
497    #[test]
498    fn slugify_leaves_short_slugs_untouched() {
499        assert_eq!(slugify("Hello World!"), "hello-world");
500        assert_eq!(slugify("Rust & Web"), "rust-web");
501    }
502
503    #[test]
504    fn split_terms_handles_non_ascii_separators() {
505        // Each script's own comma. Splitting on ASCII alone yields one term.
506        assert_eq!(split_terms("a, b, c").len(), 3);
507        assert_eq!(
508            split_terms("\u{0623}\u{060C} \u{0628}\u{060C} \u{062C}").len(),
509            3
510        );
511        assert_eq!(
512            split_terms("\u{3042}\u{3001}\u{3044}\u{3001}\u{3046}").len(),
513            3
514        );
515        assert_eq!(split_terms("\u{7532}\u{FF0C}\u{4E59}").len(), 2);
516        assert_eq!(split_terms("a; b").len(), 2);
517    }
518
519    #[test]
520    fn split_terms_trims_and_drops_empties() {
521        assert_eq!(split_terms("  a  ,,  b  ,"), vec!["a", "b"]);
522        assert!(split_terms(" , , ").is_empty());
523        assert!(split_terms("").is_empty());
524    }
525
526    #[test]
527    fn split_terms_then_slugify_stays_within_the_byte_cap() {
528        // The two fixes together: the real corpus shape. Each term is short,
529        // so nothing is truncated and no path component can overflow.
530        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";
531        let slugs: Vec<String> =
532            split_terms(list).iter().map(|t| slugify(t)).collect();
533        assert_eq!(slugs.len(), 3);
534        for s in &slugs {
535            assert!(s.len() <= MAX_SLUG_BYTES);
536            assert!(!s.is_empty());
537        }
538    }
539
540    #[test]
541    fn compile_markdown_basic() {
542        let html = compile_markdown("# Hello\n\nParagraph.");
543        assert!(html.contains("<h1>Hello</h1>"));
544        assert!(html.contains("<p>Paragraph.</p>"));
545    }
546
547    #[test]
548    fn compile_markdown_gfm_tables() {
549        let input = "| A | B |\n|---|---|\n| 1 | 2 |";
550        let html = compile_markdown(input);
551        assert!(html.contains("<table>"));
552    }
553
554    #[test]
555    fn compile_markdown_strikethrough() {
556        let html = compile_markdown("~~deleted~~");
557        assert!(html.contains("<del>deleted</del>"));
558    }
559
560    #[test]
561    fn parse_frontmatter_yaml() {
562        let (fm, body) = parse_frontmatter(
563            "---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
564        );
565        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
566        assert!(body.contains("# Body"));
567    }
568
569    #[test]
570    fn parse_frontmatter_toml() {
571        let (fm, body) =
572            parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
573        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
574        assert!(body.contains("# Body"));
575    }
576
577    #[test]
578    fn parse_frontmatter_json() {
579        let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
580        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
581        assert!(body.contains("# Body"));
582    }
583
584    #[test]
585    fn parse_frontmatter_none() {
586        let (fm, body) = parse_frontmatter("Just content");
587        assert!(fm.is_empty());
588        assert_eq!(body, "Just content");
589    }
590
591    #[test]
592    fn compile_page_full() {
593        let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
594        let (fm, html) = compile_page(input).unwrap();
595        assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
596        assert!(html.contains("<h1>Hello</h1>"));
597    }
598
599    #[test]
600    fn strip_html_tags_basic() {
601        assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
602    }
603
604    #[test]
605    fn strip_html_tags_empty() {
606        assert_eq!(strip_html_tags(""), "");
607    }
608
609    #[test]
610    fn build_search_entry_strips_tags() {
611        let entry =
612            build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
613        assert_eq!(entry.title, "Title");
614        assert_eq!(entry.content, "Hello world");
615    }
616
617    #[test]
618    fn reading_time_short() {
619        assert_eq!(reading_time("one two three"), 1);
620    }
621
622    #[test]
623    fn reading_time_long() {
624        let text = "word ".repeat(600);
625        assert_eq!(reading_time(&text), 3);
626    }
627
628    #[test]
629    fn slugify_basic() {
630        assert_eq!(slugify("Hello World!"), "hello-world");
631        assert_eq!(slugify("Rust & Web"), "rust-web");
632    }
633
634    #[test]
635    fn error_display_frontmatter_parse_variant() {
636        let e = Error::FrontmatterParse {
637            syntax: "yaml mismatch".to_string(),
638        };
639        let s = format!("{e}");
640        assert!(s.contains("Frontmatter parse error"));
641        assert!(s.contains("yaml mismatch"));
642    }
643
644    #[test]
645    fn error_display_markdown_compile_variant() {
646        let e = Error::MarkdownCompile {
647            source: "broken markdown".to_string(),
648        };
649        let s = format!("{e}");
650        assert!(s.contains("Markdown compilation error"));
651        assert!(s.contains("broken markdown"));
652    }
653
654    #[test]
655    fn error_display_invalid_slug_variant() {
656        let e = Error::InvalidSlug {
657            input: "@@@".to_string(),
658        };
659        let s = format!("{e}");
660        assert!(s.contains("Invalid slug input"));
661        assert!(s.contains("@@@"));
662    }
663
664    #[test]
665    fn error_is_std_error_trait_object() {
666        // Smoke-tests the `impl std::error::Error for Error {}` block.
667        let e: Box<dyn std::error::Error> = Box::new(Error::InvalidSlug {
668            input: "x".to_string(),
669        });
670        assert!(!e.to_string().is_empty());
671        // No source by default.
672        assert!(std::error::Error::source(&*e).is_none());
673    }
674
675    #[test]
676    fn error_debug_impl_executes_for_each_variant() {
677        let e1 = Error::FrontmatterParse {
678            syntax: "a".to_string(),
679        };
680        let e2 = Error::MarkdownCompile {
681            source: "b".to_string(),
682        };
683        let e3 = Error::InvalidSlug {
684            input: "c".to_string(),
685        };
686        for e in [&e1, &e2, &e3] {
687            let s = format!("{e:?}");
688            assert!(!s.is_empty());
689        }
690    }
691
692    #[test]
693    fn search_entry_serialization_roundtrip() {
694        let e = SearchEntry {
695            title: "T".to_string(),
696            url: "/u".to_string(),
697            content: "C".to_string(),
698        };
699        let json = serde_json::to_string(&e).unwrap();
700        assert!(json.contains("\"title\":\"T\""));
701        let back: SearchEntry = serde_json::from_str(&json).unwrap();
702        assert_eq!(back.url, "/u");
703        assert_eq!(back.content, "C");
704        // Debug + Clone are derived; exercise them.
705        let _ = format!("{back:?}");
706        let _ = back.clone();
707    }
708
709    #[test]
710    fn compile_page_yields_empty_frontmatter_when_absent() {
711        let (fm, html) = compile_page("# Heading\n\nBody").unwrap();
712        assert!(fm.is_empty());
713        assert!(html.contains("<h1>Heading</h1>"));
714    }
715
716    #[test]
717    fn slugify_collapses_consecutive_separators() {
718        assert_eq!(slugify("foo!!!bar"), "foo-bar");
719        assert_eq!(slugify("--leading--"), "leading");
720    }
721
722    #[test]
723    fn slugify_empty_input_yields_empty() {
724        assert_eq!(slugify(""), "");
725        assert_eq!(slugify("???"), "");
726    }
727}