1#![forbid(unsafe_code)]
2pub 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#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Error {
47 FrontmatterParse {
49 syntax: String,
51 },
52 MarkdownCompile {
54 source: String,
56 },
57 InvalidSlug {
59 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
82pub type Result<T> = std::result::Result<T, Error>;
84
85#[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
110pub fn parse_frontmatter(
124 input: &str,
125) -> (HashMap<String, serde_json::Value>, String) {
126 let trimmed = input.trim_start();
127
128 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 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 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 if trimmed.starts_with('{') {
171 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
202pub 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
241pub struct SearchEntry {
242 pub title: String,
244 pub url: String,
246 pub content: String,
248}
249
250#[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#[must_use]
290pub fn build_search_entry(title: &str, url: &str, html: &str) -> SearchEntry {
291 let content = strip_html_tags(html);
292 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#[must_use]
314pub fn reading_time(text: &str) -> usize {
315 (text.split_whitespace().count() / 200).max(1)
316}
317
318#[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 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 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 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}