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 (map, body) = parse_frontmatter_borrowed(input);
130 (map, body.to_string())
131}
132
133fn parse_frontmatter_borrowed(
139 input: &str,
140) -> (HashMap<String, serde_json::Value>, &str) {
141 let trimmed = input.trim_start();
142
143 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 return (map.into_iter().collect(), body);
154 }
155 return (HashMap::new(), body);
156 }
157 }
158
159 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 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 if trimmed.starts_with('{') {
183 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
214pub 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
253pub struct SearchEntry {
254 pub title: String,
256 pub url: String,
258 pub content: String,
260}
261
262#[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#[must_use]
302pub fn build_search_entry(title: &str, url: &str, html: &str) -> SearchEntry {
303 let content = strip_html_tags(html);
304 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#[must_use]
326pub fn reading_time(text: &str) -> usize {
327 (text.split_whitespace().count() / 200).max(1)
328}
329
330#[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 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 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 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}